44reports-mcp 1.1.0 → 1.2.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.
@@ -56,6 +56,22 @@ export interface Folder {
56
56
  created_at: string;
57
57
  updated_at: string;
58
58
  }
59
+ export interface BacklogEntry {
60
+ id: string;
61
+ product_slug: string | null;
62
+ content: string;
63
+ source: string | null;
64
+ source_context: string | null;
65
+ domain_hint: string | null;
66
+ tags: string | null;
67
+ status: string;
68
+ result_summary: string | null;
69
+ applied_changes: string | null;
70
+ rejection_reason: string | null;
71
+ processed_at: string | null;
72
+ created_at: string;
73
+ updated_at: string;
74
+ }
59
75
  export declare class ApiClient {
60
76
  private readonly config;
61
77
  constructor(config: Config);
@@ -120,17 +136,22 @@ export declare class ApiClient {
120
136
  removeReportFromFolder(folderSlug: string, reportSlug: string): Promise<{
121
137
  success: boolean;
122
138
  }>;
123
- listProducts(): Promise<{
139
+ listProducts(options?: {
140
+ search?: string;
141
+ limit?: number;
142
+ offset?: number;
143
+ }): Promise<{
124
144
  products: Array<{
125
145
  id: string;
126
146
  slug: string;
127
147
  name: string;
128
148
  description: string | null;
129
- json_file_count: number;
130
- binary_file_count: number;
149
+ created_at: string;
150
+ updated_at: string;
131
151
  }>;
152
+ total: number;
132
153
  }>;
133
- getProduct(slug: string): Promise<{
154
+ getProduct(slug: string, domain?: string): Promise<{
134
155
  product: {
135
156
  id: string;
136
157
  slug: string;
@@ -140,10 +161,16 @@ export declare class ApiClient {
140
161
  updated_at: string;
141
162
  };
142
163
  knowledge: Record<string, unknown>;
164
+ doc_files: Array<{
165
+ path: string;
166
+ size: number;
167
+ domain: string;
168
+ }>;
143
169
  binary_files: Array<{
144
170
  path: string;
145
171
  size: number;
146
172
  content_type: string;
173
+ domain: string;
147
174
  }>;
148
175
  }>;
149
176
  getProductFile(slug: string, filepath: string): Promise<{
@@ -168,4 +195,88 @@ export declare class ApiClient {
168
195
  success: boolean;
169
196
  uploaded: string[];
170
197
  }>;
198
+ getProductHealth(slug: string): Promise<{
199
+ slug: string;
200
+ name: string;
201
+ overall_score: number;
202
+ files: Record<string, unknown>;
203
+ recommendations: string[];
204
+ }>;
205
+ getAllProductsHealth(): Promise<{
206
+ products: Array<{
207
+ slug: string;
208
+ name: string;
209
+ overall_score: number;
210
+ files: Record<string, {
211
+ score: number;
212
+ missing_required: string[];
213
+ }>;
214
+ }>;
215
+ }>;
216
+ createProduct(data: {
217
+ name: string;
218
+ slug?: string;
219
+ description?: string;
220
+ }): Promise<{
221
+ product: {
222
+ id: string;
223
+ slug: string;
224
+ name: string;
225
+ description: string | null;
226
+ created_at: string;
227
+ updated_at: string;
228
+ };
229
+ }>;
230
+ deleteProduct(slug: string): Promise<{
231
+ success: boolean;
232
+ }>;
233
+ deleteProductFile(slug: string, filepath: string): Promise<{
234
+ success: boolean;
235
+ }>;
236
+ getProductIndex(slug: string, domain?: string): Promise<{
237
+ product_slug: string;
238
+ updated_at: string;
239
+ files: Array<{
240
+ path: string;
241
+ title: string;
242
+ summary: string;
243
+ sections: string[];
244
+ domain: string;
245
+ content_type: string;
246
+ size: number;
247
+ updated_at: string;
248
+ }>;
249
+ }>;
250
+ pullProductFiles(slug: string, options?: {
251
+ files?: string[];
252
+ include_knowledge?: boolean;
253
+ }): Promise<{
254
+ files: PulledFile[];
255
+ }>;
256
+ addBacklogEntry(data: {
257
+ content: string;
258
+ product_slug?: string;
259
+ source?: string;
260
+ source_context?: string;
261
+ domain_hint?: string;
262
+ tags?: string[];
263
+ }): Promise<{
264
+ entry: BacklogEntry;
265
+ }>;
266
+ listBacklog(options?: {
267
+ product_slug?: string;
268
+ status?: string;
269
+ limit?: number;
270
+ offset?: number;
271
+ }): Promise<{
272
+ entries: BacklogEntry[];
273
+ total: number;
274
+ }>;
275
+ deleteBacklogEntry(id: string): Promise<{
276
+ success: boolean;
277
+ }>;
278
+ processBacklog(options?: {
279
+ product_slug?: string;
280
+ dry_run?: boolean;
281
+ }): Promise<unknown>;
171
282
  }
@@ -91,11 +91,19 @@ export class ApiClient {
91
91
  });
92
92
  }
93
93
  // --- Product Knowledge ---
94
- async listProducts() {
95
- return this.request('/api/products');
94
+ async listProducts(options) {
95
+ const params = new URLSearchParams();
96
+ for (const [key, value] of Object.entries(options ?? {})) {
97
+ if (value !== undefined) {
98
+ params.set(key, String(value));
99
+ }
100
+ }
101
+ const query = params.toString();
102
+ return this.request(`/api/products${query ? `?${query}` : ''}`);
96
103
  }
97
- async getProduct(slug) {
98
- return this.request(`/api/products/${slug}`);
104
+ async getProduct(slug, domain) {
105
+ const query = domain ? `?domain=${encodeURIComponent(domain)}` : '';
106
+ return this.request(`/api/products/${slug}${query}`);
99
107
  }
100
108
  async getProductFile(slug, filepath) {
101
109
  return this.request(`/api/products/${slug}/files/${filepath}`);
@@ -112,4 +120,69 @@ export class ApiClient {
112
120
  body: JSON.stringify({ files }),
113
121
  });
114
122
  }
123
+ async getProductHealth(slug) {
124
+ return this.request(`/api/products/${slug}/health`);
125
+ }
126
+ async getAllProductsHealth() {
127
+ return this.request('/api/products/health');
128
+ }
129
+ async createProduct(data) {
130
+ return this.request('/api/products', {
131
+ method: 'POST',
132
+ body: JSON.stringify(data),
133
+ });
134
+ }
135
+ async deleteProduct(slug) {
136
+ return this.request(`/api/products/${slug}`, {
137
+ method: 'DELETE',
138
+ });
139
+ }
140
+ async deleteProductFile(slug, filepath) {
141
+ return this.request(`/api/products/${slug}/files/${filepath}`, {
142
+ method: 'DELETE',
143
+ });
144
+ }
145
+ async getProductIndex(slug, domain) {
146
+ const query = domain ? `?domain=${encodeURIComponent(domain)}` : '';
147
+ return this.request(`/api/products/${slug}/index${query}`);
148
+ }
149
+ async pullProductFiles(slug, options) {
150
+ return this.request(`/api/products/${slug}/pull`, {
151
+ method: 'POST',
152
+ body: JSON.stringify(options ?? {}),
153
+ });
154
+ }
155
+ // --- Agent Backlog ---
156
+ async addBacklogEntry(data) {
157
+ return this.request('/api/backlog', {
158
+ method: 'POST',
159
+ body: JSON.stringify(data),
160
+ });
161
+ }
162
+ async listBacklog(options) {
163
+ const params = new URLSearchParams();
164
+ for (const [key, value] of Object.entries(options ?? {})) {
165
+ if (value !== undefined) {
166
+ params.set(key, String(value));
167
+ }
168
+ }
169
+ const query = params.toString();
170
+ return this.request(`/api/backlog${query ? `?${query}` : ''}`);
171
+ }
172
+ async deleteBacklogEntry(id) {
173
+ return this.request(`/api/backlog/${id}`, {
174
+ method: 'DELETE',
175
+ });
176
+ }
177
+ async processBacklog(options) {
178
+ const params = new URLSearchParams();
179
+ if (options?.product_slug)
180
+ params.set('product_slug', options.product_slug);
181
+ if (options?.dry_run !== undefined)
182
+ params.set('dry_run', String(options.dry_run));
183
+ const query = params.toString();
184
+ return this.request(`/api/backlog/process${query ? `?${query}` : ''}`, {
185
+ method: 'POST',
186
+ });
187
+ }
115
188
  }
package/dist/index.js CHANGED
@@ -8,7 +8,8 @@ import { deployReportSchema, updateReportSchema, createDeployTool, createUpdateT
8
8
  import { listReportsSchema, getReportSchema, deleteReportSchema, createListTool, createGetTool, createDeleteTool, } from './tools/reports.js';
9
9
  import { pullContextSchema, createPullTool, } from './tools/pull.js';
10
10
  import { manageFoldersSchema, createManageFoldersTool, } from './tools/folders.js';
11
- import { listProductsSchema, getProductSchema, readProductFileSchema, updateProductKnowledgeSchema, uploadProductFilesSchema, pullProductFilesSchema, createListProductsTool, createGetProductTool, createReadProductFileTool, createUpdateProductKnowledgeTool, createUploadProductFilesTool, createPullProductFilesTool, } from './tools/products.js';
11
+ import { listProductsSchema, getProductSchema, readProductFileSchema, updateProductKnowledgeSchema, uploadProductFilesSchema, pullProductFilesSchema, createProductSchema, deleteProductSchema, deleteProductFileSchema, getProductHealthSchema, getProductIndexSchema, createListProductsTool, createGetProductTool, createReadProductFileTool, createUpdateProductKnowledgeTool, createUploadProductFilesTool, createPullProductFilesTool, createGetProductHealthTool, createGetProductIndexTool, createCreateProductTool, createDeleteProductTool, createDeleteProductFileTool, } from './tools/products.js';
12
+ import { addToBacklogSchema, listBacklogSchema, processBacklogSchema, createAddToBacklogTool, createListBacklogTool, createProcessBacklogTool, } from './tools/backlog.js';
12
13
  const config = loadConfig();
13
14
  const client = new ApiClient(config);
14
15
  function prop(type, description, extra = {}) {
@@ -139,18 +140,24 @@ const toolDefinitions = [
139
140
  },
140
141
  {
141
142
  name: 'list_products',
142
- description: 'List all 44pixels products in the knowledge repository (Cue, Deep, Pixi, Clara, Vivi, Wordcast, etc.) with their knowledge file inventory. Call this first to discover available products and which knowledge files are already populated.',
143
- inputSchema: { type: 'object', properties: {} },
143
+ description: 'List all products in the knowledge repository with their knowledge file inventory. Call this first to discover available products and which knowledge files are already populated.',
144
+ inputSchema: {
145
+ type: 'object',
146
+ properties: {
147
+ search: prop('string', 'Search term to filter products by name or description'),
148
+ },
149
+ },
144
150
  schema: listProductsSchema,
145
151
  handler: createListProductsTool(client),
146
152
  },
147
153
  {
148
154
  name: 'get_product',
149
- description: 'Get a product\'s complete knowledge — returns ALL JSON knowledge files (product.json, brand.json, copy.json, meta-ads.json, cpps.json) fully parsed, plus a listing of binary assets. This is the primary tool to read before generating creative work, writing ad copy, or configuring campaigns it gives you brand voice, product positioning, hooks, and ad account IDs in one call.',
155
+ description: 'Get a product\'s complete knowledge — returns all JSON config files (meta-ads.json, cpps.json, brand-config.json) fully parsed, plus listings of markdown docs and binary assets. This is the primary tool to read before generating creative work, writing ad copy, or configuring campaigns. For narrative knowledge (brand voice, product positioning, research), use get_product_index to find the right markdown doc, then read_product_file to get its content.\n\nOptionally filter by domain to get only files relevant to a specific area: product-research, brand, copy, advertising, or app-store. Note: JSON config files are domain-scoped (meta-ads.json → advertising, brand-config.json → brand, cpps.json → app-store), so domains without a config file will return an empty knowledge object.',
150
156
  inputSchema: {
151
157
  type: 'object',
152
158
  properties: {
153
159
  slug: prop('string', 'Product slug'),
160
+ domain: prop('string', 'Filter files by domain', { enum: ['product-research', 'brand', 'copy', 'advertising', 'app-store'] }),
154
161
  },
155
162
  required: ['slug'],
156
163
  },
@@ -159,12 +166,12 @@ const toolDefinitions = [
159
166
  },
160
167
  {
161
168
  name: 'read_product_file',
162
- description: 'Read a single file from a product by path. Use get_product to read all knowledge files at once (more efficient). Use read_product_file only when you need one specific file — especially useful for binary files (logos, PDFs) returned as base64 data URIs.',
169
+ description: 'Read a single file from a product by path. Use get_product to read all knowledge files at once (more efficient). Use read_product_file only when you need one specific file — works for markdown docs, JSON configs, and binary files (logos, PDFs returned as base64 data URIs).\n\nTypical workflow: get_product_index → find the file you need → read_product_file.',
163
170
  inputSchema: {
164
171
  type: 'object',
165
172
  properties: {
166
173
  slug: prop('string', 'Product slug'),
167
- filepath: prop('string', 'File path within the product (e.g., "meta-ads.json", "brand-assets/logo.png")'),
174
+ filepath: prop('string', 'File path within the product (e.g., "docs/research/user-research.md", "meta-ads.json", "brand-assets/logo.png")'),
168
175
  },
169
176
  required: ['slug', 'filepath'],
170
177
  },
@@ -173,12 +180,12 @@ const toolDefinitions = [
173
180
  },
174
181
  {
175
182
  name: 'update_product_knowledge',
176
- description: 'Write or update product knowledge using deep merge-patch — only the keys you send are changed, all other data is preserved. This is the canonical way to store brand knowledge: ALWAYS use this (not deploy_context) for product profiles, brand guidelines, copy, ad accounts, and CPP config.\n\nKnowledge files per product:\n- product.json — core info, target audience, pain points, selling points, competitors\n- brand.json — colors, typography, voice/tone, brand archetypes, power words\n- copy.jsontaglines, hooks, CTAs, approved/banned claims\n- meta-ads.jsonMeta account IDs, pixels, creative test config\n- cpps.jsonCPP motivations and custom product page config\n\nAlways pass updated_by with your agent name so humans can see who wrote each update.',
183
+ description: 'Write or update product knowledge JSON config using deep merge-patch — only the keys you send are changed, all other data is preserved. Use this for programmatic config files that agents plug into API calls.\n\nConfig files per product:\n- meta-ads.json Meta account IDs, pixels, creative test config\n- cpps.json — CPP motivations and custom product page config\n- brand-config.json — brand color palette hex values, font families\n\nFor narrative knowledge (product info, brand voice, copy, research), create or update Markdown docs using upload_product_files instead. Place docs in the correct domain prefix:\n- docs/research/user research, competitive analysis, hypotheses, GTM strategy, roadmap\n- docs/brand/brand voice, positioning\n- docs/copy/ copy guidelines, messaging\n- docs/advertising/campaign briefs, ad copy\n- docs/app-store/ store listing copy, metadata\n\nAlways pass updated_by with your agent name so humans can see who wrote each update.',
177
184
  inputSchema: {
178
185
  type: 'object',
179
186
  properties: {
180
187
  slug: prop('string', 'Product slug'),
181
- filepath: prop('string', 'Knowledge file to update (e.g., "meta-ads.json", "brand.json", "product.json", "copy.json", "cpps.json")'),
188
+ filepath: prop('string', 'Config file to update (e.g., "meta-ads.json", "cpps.json", "brand-config.json")'),
182
189
  content: prop('object', 'JSON object to deep merge-patch into the knowledge file. Use null values to delete keys.'),
183
190
  updated_by: prop('string', 'Agent name or ID writing the update (optional)'),
184
191
  },
@@ -189,7 +196,7 @@ const toolDefinitions = [
189
196
  },
190
197
  {
191
198
  name: 'upload_product_files',
192
- description: 'Upload binary assets to a product — logos, brand books, App Store screenshots, ad creatives, videos. Files are organized into sections:\n- brand-assets/ — logos, brand book PDFs, color swatches\n- store-listings/ — App Store / Play Store screenshots and preview videos\n- assets/ — ad creatives, icons, campaign videos',
199
+ description: 'Upload files to a product — binary assets or Markdown knowledge docs. Files are organized into sections:\n\nBinary assets:\n- brand-assets/ — logos, brand book PDFs, color swatches\n- store-listings/ — App Store / Play Store screenshots and preview videos\n- assets/ — ad creatives, icons, campaign videos\n\nMarkdown docs (use these prefixes for knowledge documents):\n- docs/research/ — user research, competitive analysis, hypotheses, GTM strategy, roadmap\n- docs/brand/ — brand voice, positioning\n- docs/copy/ — copy guidelines, messaging\n- docs/advertising/ — campaign briefs, ad copy\n- docs/app-store/ — store listing copy, metadata',
193
200
  inputSchema: {
194
201
  type: 'object',
195
202
  properties: {
@@ -213,19 +220,134 @@ const toolDefinitions = [
213
220
  },
214
221
  {
215
222
  name: 'pull_product_files',
216
- description: 'Download binary files from a product to a local directory.',
223
+ description: 'Download files from a product to a local directory. By default pulls binary assets (logos, screenshots, videos). Set include_knowledge to also pull JSON config files. To pull markdown docs, specify their paths in the files array (e.g., ["docs/research/user-research.md"]).',
217
224
  inputSchema: {
218
225
  type: 'object',
219
226
  properties: {
220
227
  slug: prop('string', 'Product slug'),
221
228
  output_dir: prop('string', 'Local directory to write files to. Will be created if needed.'),
222
229
  files: prop('array', 'Specific file paths to pull. If omitted, all binary files are pulled.', { items: { type: 'string' } }),
230
+ include_knowledge: prop('boolean', 'Include knowledge JSON files in addition to binary files. Default: false.'),
223
231
  },
224
232
  required: ['slug', 'output_dir'],
225
233
  },
226
234
  schema: pullProductFilesSchema,
227
235
  handler: createPullProductFilesTool(client),
228
236
  },
237
+ {
238
+ name: 'get_product_health',
239
+ description: 'Get knowledge completeness scores for a product or all products. Scores both JSON config files (field-level completeness) and expected markdown docs (presence check). Shows missing required/optional fields and missing docs, with an overall health score (0-100). Use to identify gaps before generating content.',
240
+ inputSchema: {
241
+ type: 'object',
242
+ properties: {
243
+ slug: prop('string', 'Product slug. If omitted, returns health summary for all products.'),
244
+ },
245
+ },
246
+ schema: getProductHealthSchema,
247
+ handler: createGetProductHealthTool(client),
248
+ },
249
+ {
250
+ name: 'get_product_index',
251
+ description: 'Get a lightweight file index for a product — a table of contents listing every file with its title, summary, sections, domain, and size. Use this FIRST to discover what knowledge is available before pulling specific files. Much faster than get_product since it returns metadata only, not file contents.\n\nTypical workflow: list_products → get_product_index → read_product_file (for specific docs) or get_product (for all JSON configs at once).\n\nOptionally filter by domain to see only files in a specific area: product-research, brand, copy, advertising, or app-store.',
252
+ inputSchema: {
253
+ type: 'object',
254
+ properties: {
255
+ slug: prop('string', 'Product slug'),
256
+ domain: prop('string', 'Filter index entries by domain', { enum: ['product-research', 'brand', 'copy', 'advertising', 'app-store'] }),
257
+ },
258
+ required: ['slug'],
259
+ },
260
+ schema: getProductIndexSchema,
261
+ handler: createGetProductIndexTool(client),
262
+ },
263
+ {
264
+ name: 'create_product',
265
+ description: 'Create a new product in the knowledge repository. The slug is auto-generated from the name if not provided. Use this before uploading knowledge files or assets.',
266
+ inputSchema: {
267
+ type: 'object',
268
+ properties: {
269
+ name: prop('string', 'Product name'),
270
+ slug: prop('string', 'URL-friendly slug (auto-generated from name if not provided)'),
271
+ description: prop('string', 'Product description'),
272
+ },
273
+ required: ['name'],
274
+ },
275
+ schema: createProductSchema,
276
+ handler: createCreateProductTool(client),
277
+ },
278
+ {
279
+ name: 'delete_product',
280
+ description: 'Delete a product and all its associated knowledge files and assets from the repository. This action is irreversible.',
281
+ inputSchema: {
282
+ type: 'object',
283
+ properties: {
284
+ slug: prop('string', 'Product slug to delete'),
285
+ },
286
+ required: ['slug'],
287
+ },
288
+ schema: deleteProductSchema,
289
+ handler: createDeleteProductTool(client),
290
+ },
291
+ {
292
+ name: 'delete_product_file',
293
+ description: 'Delete a single file from a product. Use for removing outdated assets or resetting a knowledge file.',
294
+ inputSchema: {
295
+ type: 'object',
296
+ properties: {
297
+ slug: prop('string', 'Product slug'),
298
+ filepath: prop('string', 'File path within the product to delete (e.g., "brand-assets/old-logo.png")'),
299
+ },
300
+ required: ['slug', 'filepath'],
301
+ },
302
+ schema: deleteProductFileSchema,
303
+ handler: createDeleteProductFileTool(client),
304
+ },
305
+ {
306
+ name: 'add_to_backlog',
307
+ description: 'Add an unstructured observation to the agent backlog for later processing. Low-friction way for any agent to contribute signals — competitor insights, user feedback, research findings, or any data that should eventually enrich product knowledge. Only content is required; everything else is optional.\n\nIf you know which product this relates to, pass product_slug. Otherwise, omit it — the backlog processor will auto-route it to the right product (or reject it as noise).',
308
+ inputSchema: {
309
+ type: 'object',
310
+ properties: {
311
+ content: prop('string', 'The observation or data (100-2000 chars typical). Be specific — include sources, numbers, quotes when available.'),
312
+ product_slug: prop('string', 'Product slug if known. Omit for global entries that will be auto-routed.'),
313
+ source: prop('string', 'Your agent name or ID (e.g., "research-agent", "competitor-monitor")'),
314
+ source_context: prop('string', 'Context slug this observation came from, if applicable'),
315
+ domain_hint: prop('string', 'Knowledge domain hint', { enum: ['product-research', 'brand', 'copy', 'advertising', 'app-store'] }),
316
+ tags: prop('array', 'Free-form tags for categorization', { items: { type: 'string' } }),
317
+ },
318
+ required: ['content'],
319
+ },
320
+ schema: addToBacklogSchema,
321
+ handler: createAddToBacklogTool(client),
322
+ },
323
+ {
324
+ name: 'list_backlog',
325
+ description: 'List backlog entries — inspect the queue of pending observations waiting to be processed into product knowledge. Filter by product, status, or both.',
326
+ inputSchema: {
327
+ type: 'object',
328
+ properties: {
329
+ product_slug: prop('string', 'Filter by product slug. Use "global" for unrouted entries without a product.'),
330
+ status: prop('string', 'Filter by status', { enum: ['pending', 'processing', 'applied', 'rejected', 'partial'] }),
331
+ limit: prop('number', 'Maximum number of results (default: 50)'),
332
+ offset: prop('number', 'Offset for pagination'),
333
+ },
334
+ },
335
+ schema: listBacklogSchema,
336
+ handler: createListBacklogTool(client),
337
+ },
338
+ {
339
+ name: 'process_backlog',
340
+ description: 'Trigger backlog processing — routes global entries to products, then uses LLM to triage and apply changes to product knowledge (JSON configs and markdown docs). Defaults to dry_run=true for safety.\n\nProcessing stages:\n1. Route: Global entries (no product_slug) are matched to products or rejected\n2. Process: Per-product entries are classified and generate JSON patches or markdown operations\n3. Apply: Only high-confidence changes are auto-applied; medium/low are marked as partial with suggestions',
341
+ inputSchema: {
342
+ type: 'object',
343
+ properties: {
344
+ product_slug: prop('string', 'Process entries for a specific product only. Omit to process all products including global routing.'),
345
+ dry_run: prop('boolean', 'Preview changes without applying them. Default: true.'),
346
+ },
347
+ },
348
+ schema: processBacklogSchema,
349
+ handler: createProcessBacklogTool(client),
350
+ },
229
351
  ];
230
352
  const toolHandlers = Object.fromEntries(toolDefinitions.map((t) => [t.name, { schema: t.schema, handler: t.handler }]));
231
353
  const server = new Server({ name: '44reports', version: '1.0.0' }, { capabilities: { tools: {} } });
@@ -0,0 +1,58 @@
1
+ import { z } from 'zod';
2
+ import type { ApiClient } from '../api-client.js';
3
+ export declare const addToBacklogSchema: z.ZodObject<{
4
+ content: z.ZodString;
5
+ product_slug: z.ZodOptional<z.ZodString>;
6
+ source: z.ZodOptional<z.ZodString>;
7
+ source_context: z.ZodOptional<z.ZodString>;
8
+ domain_hint: z.ZodOptional<z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>>;
9
+ tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
10
+ }, "strip", z.ZodTypeAny, {
11
+ content: string;
12
+ product_slug?: string | undefined;
13
+ tags?: string[] | undefined;
14
+ source?: string | undefined;
15
+ source_context?: string | undefined;
16
+ domain_hint?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
17
+ }, {
18
+ content: string;
19
+ product_slug?: string | undefined;
20
+ tags?: string[] | undefined;
21
+ source?: string | undefined;
22
+ source_context?: string | undefined;
23
+ domain_hint?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
24
+ }>;
25
+ export declare const listBacklogSchema: z.ZodObject<{
26
+ product_slug: z.ZodOptional<z.ZodString>;
27
+ status: z.ZodOptional<z.ZodEnum<["pending", "processing", "applied", "rejected", "partial"]>>;
28
+ limit: z.ZodOptional<z.ZodNumber>;
29
+ offset: z.ZodOptional<z.ZodNumber>;
30
+ }, "strip", z.ZodTypeAny, {
31
+ status?: "pending" | "processing" | "applied" | "rejected" | "partial" | undefined;
32
+ limit?: number | undefined;
33
+ offset?: number | undefined;
34
+ product_slug?: string | undefined;
35
+ }, {
36
+ status?: "pending" | "processing" | "applied" | "rejected" | "partial" | undefined;
37
+ limit?: number | undefined;
38
+ offset?: number | undefined;
39
+ product_slug?: string | undefined;
40
+ }>;
41
+ export declare const processBacklogSchema: z.ZodObject<{
42
+ product_slug: z.ZodOptional<z.ZodString>;
43
+ dry_run: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
44
+ }, "strip", z.ZodTypeAny, {
45
+ dry_run: boolean;
46
+ product_slug?: string | undefined;
47
+ }, {
48
+ product_slug?: string | undefined;
49
+ dry_run?: boolean | undefined;
50
+ }>;
51
+ export declare function createAddToBacklogTool(client: ApiClient): (input: z.infer<typeof addToBacklogSchema>) => Promise<{
52
+ entry: import("../api-client.js").BacklogEntry;
53
+ }>;
54
+ export declare function createListBacklogTool(client: ApiClient): (input: z.infer<typeof listBacklogSchema>) => Promise<{
55
+ entries: import("../api-client.js").BacklogEntry[];
56
+ total: number;
57
+ }>;
58
+ export declare function createProcessBacklogTool(client: ApiClient): (input: z.infer<typeof processBacklogSchema>) => Promise<unknown>;
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ const DOMAIN_VALUES = ['product-research', 'brand', 'copy', 'advertising', 'app-store'];
3
+ const STATUS_VALUES = ['pending', 'processing', 'applied', 'rejected', 'partial'];
4
+ // --- Schemas ---
5
+ export const addToBacklogSchema = z.object({
6
+ content: z.string().describe('The observation or data to add to the backlog'),
7
+ product_slug: z.string().optional().describe('Product slug — omit for global entries that will be auto-routed'),
8
+ source: z.string().optional().describe('Agent name or ID submitting this entry'),
9
+ source_context: z.string().optional().describe('Context slug this observation came from'),
10
+ domain_hint: z.enum(DOMAIN_VALUES).optional().describe('Knowledge domain hint'),
11
+ tags: z.array(z.string()).optional().describe('Free-form tags for categorization'),
12
+ });
13
+ export const listBacklogSchema = z.object({
14
+ product_slug: z.string().optional().describe('Filter by product slug. Use "global" for unrouted entries.'),
15
+ status: z.enum(STATUS_VALUES).optional().describe('Filter by status'),
16
+ limit: z.number().optional().describe('Maximum number of results (default: 50)'),
17
+ offset: z.number().optional().describe('Offset for pagination'),
18
+ });
19
+ export const processBacklogSchema = z.object({
20
+ product_slug: z.string().optional().describe('Process entries for a specific product. Omit to process all including global routing.'),
21
+ dry_run: z.boolean().optional().default(true).describe('Preview changes without applying. Default: true.'),
22
+ });
23
+ // --- Tool factories ---
24
+ export function createAddToBacklogTool(client) {
25
+ return async (input) => {
26
+ return client.addBacklogEntry(input);
27
+ };
28
+ }
29
+ export function createListBacklogTool(client) {
30
+ return async (input) => {
31
+ return client.listBacklog({
32
+ product_slug: input.product_slug,
33
+ status: input.status,
34
+ limit: input.limit,
35
+ offset: input.offset,
36
+ });
37
+ };
38
+ }
39
+ export function createProcessBacklogTool(client) {
40
+ return async (input) => {
41
+ return client.processBacklog({
42
+ product_slug: input.product_slug,
43
+ dry_run: input.dry_run,
44
+ });
45
+ };
46
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,255 @@
1
+ import { addToBacklogSchema, listBacklogSchema, processBacklogSchema, createAddToBacklogTool, createListBacklogTool, createProcessBacklogTool, } from './backlog.js';
2
+ // --- Schema validation tests ---
3
+ describe('addToBacklogSchema', () => {
4
+ it('requires content', () => {
5
+ const result = addToBacklogSchema.safeParse({});
6
+ expect(result.success).toBe(false);
7
+ });
8
+ it('accepts content only', () => {
9
+ const result = addToBacklogSchema.safeParse({ content: 'User mentioned they love the dark mode feature' });
10
+ expect(result.success).toBe(true);
11
+ });
12
+ it('accepts all optional fields', () => {
13
+ const result = addToBacklogSchema.safeParse({
14
+ content: 'Competitor launched new pricing tier at $9.99/mo',
15
+ product_slug: 'my-app',
16
+ source: 'competitor-monitor',
17
+ source_context: 'weekly-research',
18
+ domain_hint: 'product-research',
19
+ tags: ['competitor', 'pricing'],
20
+ });
21
+ expect(result.success).toBe(true);
22
+ if (result.success) {
23
+ expect(result.data.product_slug).toBe('my-app');
24
+ expect(result.data.tags).toEqual(['competitor', 'pricing']);
25
+ }
26
+ });
27
+ it('rejects invalid domain_hint', () => {
28
+ const result = addToBacklogSchema.safeParse({
29
+ content: 'Some observation',
30
+ domain_hint: 'invalid-domain',
31
+ });
32
+ expect(result.success).toBe(false);
33
+ });
34
+ });
35
+ describe('listBacklogSchema', () => {
36
+ it('accepts empty input', () => {
37
+ const result = listBacklogSchema.safeParse({});
38
+ expect(result.success).toBe(true);
39
+ });
40
+ it('accepts all filters', () => {
41
+ const result = listBacklogSchema.safeParse({
42
+ product_slug: 'my-app',
43
+ status: 'pending',
44
+ limit: 10,
45
+ offset: 20,
46
+ });
47
+ expect(result.success).toBe(true);
48
+ });
49
+ it('rejects invalid status', () => {
50
+ const result = listBacklogSchema.safeParse({ status: 'invalid' });
51
+ expect(result.success).toBe(false);
52
+ });
53
+ });
54
+ describe('processBacklogSchema', () => {
55
+ it('accepts empty input with dry_run defaulting to true', () => {
56
+ const result = processBacklogSchema.safeParse({});
57
+ expect(result.success).toBe(true);
58
+ if (result.success) {
59
+ expect(result.data.dry_run).toBe(true);
60
+ }
61
+ });
62
+ it('accepts product_slug and dry_run false', () => {
63
+ const result = processBacklogSchema.safeParse({
64
+ product_slug: 'my-app',
65
+ dry_run: false,
66
+ });
67
+ expect(result.success).toBe(true);
68
+ if (result.success) {
69
+ expect(result.data.dry_run).toBe(false);
70
+ }
71
+ });
72
+ });
73
+ function makeEntry(overrides = {}) {
74
+ return {
75
+ id: 'entry-1',
76
+ product_slug: null,
77
+ content: 'Test observation',
78
+ source: null,
79
+ source_context: null,
80
+ domain_hint: null,
81
+ tags: null,
82
+ status: 'pending',
83
+ result_summary: null,
84
+ applied_changes: null,
85
+ rejection_reason: null,
86
+ processed_at: null,
87
+ created_at: '2026-03-06T00:00:00Z',
88
+ updated_at: '2026-03-06T00:00:00Z',
89
+ ...overrides,
90
+ };
91
+ }
92
+ /** Simple call tracker — records arguments passed to each mock method */
93
+ function createTrackedClient(mocks) {
94
+ const calls = {
95
+ addBacklogEntry: [],
96
+ listBacklog: [],
97
+ processBacklog: [],
98
+ };
99
+ const client = {
100
+ addBacklogEntry: async (data) => {
101
+ calls.addBacklogEntry.push(data);
102
+ return mocks.addBacklogEntry?.(data) ?? {};
103
+ },
104
+ listBacklog: async (options) => {
105
+ calls.listBacklog.push(options);
106
+ return mocks.listBacklog?.(options) ?? { entries: [], total: 0 };
107
+ },
108
+ processBacklog: async (options) => {
109
+ calls.processBacklog.push(options);
110
+ return mocks.processBacklog?.(options) ?? {};
111
+ },
112
+ };
113
+ return { client, calls };
114
+ }
115
+ describe('add_to_backlog → list_backlog → process_backlog flow', () => {
116
+ it('adds an entry, lists it, then processes it', async () => {
117
+ const entry = makeEntry({
118
+ content: 'Users report competitor app Calm added sleep stories',
119
+ product_slug: 'my-meditation-app',
120
+ source: 'research-agent',
121
+ domain_hint: 'product-research',
122
+ tags: '["competitor","feature"]',
123
+ });
124
+ const processResponse = {
125
+ routed: 0,
126
+ processed: 1,
127
+ applied: 1,
128
+ rejected: 0,
129
+ partial: 0,
130
+ errors: [],
131
+ details: [{
132
+ id: entry.id,
133
+ status: 'applied',
134
+ result_summary: 'Added competitor insight to research doc',
135
+ applied_changes: [{ filepath: 'docs/research/competitors.md', action: 'md_append', product_slug: 'my-meditation-app' }],
136
+ rejection_reason: null,
137
+ }],
138
+ };
139
+ const { client, calls } = createTrackedClient({
140
+ addBacklogEntry: async () => ({ entry }),
141
+ listBacklog: async () => ({ entries: [entry], total: 1 }),
142
+ processBacklog: async () => processResponse,
143
+ });
144
+ // Step 1: Add entry
145
+ const addTool = createAddToBacklogTool(client);
146
+ const addResult = await addTool({
147
+ content: 'Users report competitor app Calm added sleep stories',
148
+ product_slug: 'my-meditation-app',
149
+ source: 'research-agent',
150
+ domain_hint: 'product-research',
151
+ tags: ['competitor', 'feature'],
152
+ });
153
+ expect(calls.addBacklogEntry).toHaveLength(1);
154
+ expect(calls.addBacklogEntry[0]).toEqual({
155
+ content: 'Users report competitor app Calm added sleep stories',
156
+ product_slug: 'my-meditation-app',
157
+ source: 'research-agent',
158
+ domain_hint: 'product-research',
159
+ tags: ['competitor', 'feature'],
160
+ });
161
+ expect(addResult.entry.id).toBe('entry-1');
162
+ expect(addResult.entry.status).toBe('pending');
163
+ // Step 2: List entries — verify pending entry appears
164
+ const listTool = createListBacklogTool(client);
165
+ const listResult = await listTool({
166
+ product_slug: 'my-meditation-app',
167
+ status: 'pending',
168
+ });
169
+ expect(calls.listBacklog).toHaveLength(1);
170
+ expect(calls.listBacklog[0].product_slug).toBe('my-meditation-app');
171
+ expect(calls.listBacklog[0].status).toBe('pending');
172
+ expect(listResult.entries).toHaveLength(1);
173
+ expect(listResult.total).toBe(1);
174
+ // Step 3: Process — verify changes applied
175
+ const processTool = createProcessBacklogTool(client);
176
+ const processResult = await processTool({
177
+ product_slug: 'my-meditation-app',
178
+ dry_run: false,
179
+ });
180
+ expect(calls.processBacklog).toHaveLength(1);
181
+ expect(calls.processBacklog[0].dry_run).toBe(false);
182
+ expect(processResult.applied).toBe(1);
183
+ expect(processResult.errors).toHaveLength(0);
184
+ expect(processResult.details[0].status).toBe('applied');
185
+ expect(processResult.details[0].applied_changes).toEqual([
186
+ { filepath: 'docs/research/competitors.md', action: 'md_append', product_slug: 'my-meditation-app' },
187
+ ]);
188
+ });
189
+ it('adds a global entry and processes with routing', async () => {
190
+ const globalEntry = makeEntry({
191
+ id: 'global-1',
192
+ content: 'App Store review mentions slow load times on iPhone 12',
193
+ source: 'review-monitor',
194
+ });
195
+ const processResponse = {
196
+ routed: 1,
197
+ processed: 1,
198
+ applied: 0,
199
+ rejected: 0,
200
+ partial: 1,
201
+ errors: [],
202
+ details: [{
203
+ id: 'global-1',
204
+ status: 'partial',
205
+ result_summary: 'medium confidence — needs review',
206
+ applied_changes: [{ filepath: 'docs/app-store/feedback.md', action: 'md_append', product_slug: 'my-app' }],
207
+ rejection_reason: null,
208
+ }],
209
+ };
210
+ const { client, calls } = createTrackedClient({
211
+ addBacklogEntry: async () => ({ entry: globalEntry }),
212
+ processBacklog: async () => processResponse,
213
+ });
214
+ // Add global entry (no product_slug)
215
+ const addTool = createAddToBacklogTool(client);
216
+ const addResult = await addTool({
217
+ content: 'App Store review mentions slow load times on iPhone 12',
218
+ source: 'review-monitor',
219
+ });
220
+ expect(addResult.entry.product_slug).toBeNull();
221
+ // Process all (triggers routing + processing)
222
+ const processTool = createProcessBacklogTool(client);
223
+ const processResult = await processTool({ dry_run: true });
224
+ expect(calls.processBacklog[0].product_slug).toBeUndefined();
225
+ expect(calls.processBacklog[0].dry_run).toBe(true);
226
+ expect(processResult.routed).toBe(1);
227
+ expect(processResult.details[0].status).toBe('partial');
228
+ });
229
+ it('dry_run defaults to true and previews without applying', async () => {
230
+ const processResponse = {
231
+ routed: 0,
232
+ processed: 2,
233
+ applied: 0,
234
+ rejected: 0,
235
+ partial: 0,
236
+ errors: [],
237
+ details: [
238
+ { id: 'e1', status: 'pending', result_summary: '[DRY RUN] high confidence: fits brand config', applied_changes: [{ filepath: 'brand-config.json', action: 'json_patch', product_slug: 'my-app' }], rejection_reason: null },
239
+ { id: 'e2', status: 'pending', result_summary: '[DRY RUN] medium confidence: could be competitor data', applied_changes: [{ filepath: 'docs/research/competitors.md', action: 'md_append', product_slug: 'my-app' }], rejection_reason: null },
240
+ ],
241
+ };
242
+ const { client, calls } = createTrackedClient({
243
+ processBacklog: async () => processResponse,
244
+ });
245
+ const processTool = createProcessBacklogTool(client);
246
+ // No dry_run passed — should default to true via Zod schema
247
+ const parsed = processBacklogSchema.parse({ product_slug: 'my-app' });
248
+ expect(parsed.dry_run).toBe(true);
249
+ const result = await processTool({ product_slug: 'my-app', dry_run: true });
250
+ expect(calls.processBacklog[0].dry_run).toBe(true);
251
+ expect(result.applied).toBe(0);
252
+ expect(result.details).toHaveLength(2);
253
+ expect(result.details[0].result_summary).toContain('[DRY RUN]');
254
+ });
255
+ });
@@ -1,12 +1,21 @@
1
1
  import { z } from 'zod';
2
2
  import type { ApiClient } from '../api-client.js';
3
- export declare const listProductsSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
3
+ export declare const listProductsSchema: z.ZodObject<{
4
+ search: z.ZodOptional<z.ZodString>;
5
+ }, "strip", z.ZodTypeAny, {
6
+ search?: string | undefined;
7
+ }, {
8
+ search?: string | undefined;
9
+ }>;
4
10
  export declare const getProductSchema: z.ZodObject<{
5
11
  slug: z.ZodString;
12
+ domain: z.ZodOptional<z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>>;
6
13
  }, "strip", z.ZodTypeAny, {
7
14
  slug: string;
15
+ domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
8
16
  }, {
9
17
  slug: string;
18
+ domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
10
19
  }>;
11
20
  export declare const readProductFileSchema: z.ZodObject<{
12
21
  slug: z.ZodString;
@@ -68,24 +77,75 @@ export declare const pullProductFilesSchema: z.ZodObject<{
68
77
  slug: z.ZodString;
69
78
  output_dir: z.ZodString;
70
79
  files: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
80
+ include_knowledge: z.ZodOptional<z.ZodBoolean>;
71
81
  }, "strip", z.ZodTypeAny, {
72
82
  slug: string;
73
83
  output_dir: string;
74
84
  files?: string[] | undefined;
85
+ include_knowledge?: boolean | undefined;
75
86
  }, {
76
87
  slug: string;
77
88
  output_dir: string;
78
89
  files?: string[] | undefined;
90
+ include_knowledge?: boolean | undefined;
91
+ }>;
92
+ export declare const getProductIndexSchema: z.ZodObject<{
93
+ slug: z.ZodString;
94
+ domain: z.ZodOptional<z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>>;
95
+ }, "strip", z.ZodTypeAny, {
96
+ slug: string;
97
+ domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
98
+ }, {
99
+ slug: string;
100
+ domain?: "product-research" | "brand" | "copy" | "advertising" | "app-store" | undefined;
101
+ }>;
102
+ export declare const getProductHealthSchema: z.ZodObject<{
103
+ slug: z.ZodOptional<z.ZodString>;
104
+ }, "strip", z.ZodTypeAny, {
105
+ slug?: string | undefined;
106
+ }, {
107
+ slug?: string | undefined;
108
+ }>;
109
+ export declare const createProductSchema: z.ZodObject<{
110
+ name: z.ZodString;
111
+ slug: z.ZodOptional<z.ZodString>;
112
+ description: z.ZodOptional<z.ZodString>;
113
+ }, "strip", z.ZodTypeAny, {
114
+ name: string;
115
+ slug?: string | undefined;
116
+ description?: string | undefined;
117
+ }, {
118
+ name: string;
119
+ slug?: string | undefined;
120
+ description?: string | undefined;
121
+ }>;
122
+ export declare const deleteProductSchema: z.ZodObject<{
123
+ slug: z.ZodString;
124
+ }, "strip", z.ZodTypeAny, {
125
+ slug: string;
126
+ }, {
127
+ slug: string;
128
+ }>;
129
+ export declare const deleteProductFileSchema: z.ZodObject<{
130
+ slug: z.ZodString;
131
+ filepath: z.ZodString;
132
+ }, "strip", z.ZodTypeAny, {
133
+ slug: string;
134
+ filepath: string;
135
+ }, {
136
+ slug: string;
137
+ filepath: string;
79
138
  }>;
80
- export declare function createListProductsTool(client: ApiClient): (_input: z.infer<typeof listProductsSchema>) => Promise<{
139
+ export declare function createListProductsTool(client: ApiClient): (input: z.infer<typeof listProductsSchema>) => Promise<{
81
140
  products: Array<{
82
141
  id: string;
83
142
  slug: string;
84
143
  name: string;
85
144
  description: string | null;
86
- json_file_count: number;
87
- binary_file_count: number;
145
+ created_at: string;
146
+ updated_at: string;
88
147
  }>;
148
+ total: number;
89
149
  }>;
90
150
  export declare function createGetProductTool(client: ApiClient): (input: z.infer<typeof getProductSchema>) => Promise<{
91
151
  product: {
@@ -97,10 +157,16 @@ export declare function createGetProductTool(client: ApiClient): (input: z.infer
97
157
  updated_at: string;
98
158
  };
99
159
  knowledge: Record<string, unknown>;
160
+ doc_files: Array<{
161
+ path: string;
162
+ size: number;
163
+ domain: string;
164
+ }>;
100
165
  binary_files: Array<{
101
166
  path: string;
102
167
  size: number;
103
168
  content_type: string;
169
+ domain: string;
104
170
  }>;
105
171
  }>;
106
172
  export declare function createReadProductFileTool(client: ApiClient): (input: z.infer<typeof readProductFileSchema>) => Promise<{
@@ -119,10 +185,6 @@ export declare function createUploadProductFilesTool(client: ApiClient): (input:
119
185
  uploaded: string[];
120
186
  }>;
121
187
  export declare function createPullProductFilesTool(client: ApiClient): (input: z.infer<typeof pullProductFilesSchema>) => Promise<{
122
- product: {
123
- name: string;
124
- slug: string;
125
- };
126
188
  files_pulled: number;
127
189
  output_dir: string;
128
190
  files: {
@@ -130,3 +192,50 @@ export declare function createPullProductFilesTool(client: ApiClient): (input: z
130
192
  size: number;
131
193
  }[];
132
194
  }>;
195
+ export declare function createGetProductIndexTool(client: ApiClient): (input: z.infer<typeof getProductIndexSchema>) => Promise<{
196
+ product_slug: string;
197
+ updated_at: string;
198
+ files: Array<{
199
+ path: string;
200
+ title: string;
201
+ summary: string;
202
+ sections: string[];
203
+ domain: string;
204
+ content_type: string;
205
+ size: number;
206
+ updated_at: string;
207
+ }>;
208
+ }>;
209
+ export declare function createGetProductHealthTool(client: ApiClient): (input: z.infer<typeof getProductHealthSchema>) => Promise<{
210
+ slug: string;
211
+ name: string;
212
+ overall_score: number;
213
+ files: Record<string, unknown>;
214
+ recommendations: string[];
215
+ } | {
216
+ products: Array<{
217
+ slug: string;
218
+ name: string;
219
+ overall_score: number;
220
+ files: Record<string, {
221
+ score: number;
222
+ missing_required: string[];
223
+ }>;
224
+ }>;
225
+ }>;
226
+ export declare function createCreateProductTool(client: ApiClient): (input: z.infer<typeof createProductSchema>) => Promise<{
227
+ product: {
228
+ id: string;
229
+ slug: string;
230
+ name: string;
231
+ description: string | null;
232
+ created_at: string;
233
+ updated_at: string;
234
+ };
235
+ }>;
236
+ export declare function createDeleteProductTool(client: ApiClient): (input: z.infer<typeof deleteProductSchema>) => Promise<{
237
+ success: boolean;
238
+ }>;
239
+ export declare function createDeleteProductFileTool(client: ApiClient): (input: z.infer<typeof deleteProductFileSchema>) => Promise<{
240
+ success: boolean;
241
+ }>;
@@ -1,25 +1,30 @@
1
1
  import { z } from 'zod';
2
2
  import * as fs from 'fs/promises';
3
3
  import * as path from 'path';
4
+ import { getMimeType } from './deploy.js';
4
5
  // --- Schemas ---
5
- export const listProductsSchema = z.object({});
6
+ export const listProductsSchema = z.object({
7
+ search: z.string().optional().describe('Search term to filter products by name or description'),
8
+ });
9
+ const DOMAIN_VALUES = ['product-research', 'brand', 'copy', 'advertising', 'app-store'];
6
10
  export const getProductSchema = z.object({
7
11
  slug: z.string().describe('Product slug'),
12
+ domain: z.enum(DOMAIN_VALUES).optional().describe('Filter files by domain'),
8
13
  });
9
14
  export const readProductFileSchema = z.object({
10
15
  slug: z.string().describe('Product slug'),
11
- filepath: z.string().describe('File path within the product (e.g., "meta-ads.json", "brand-assets/logo.png")'),
16
+ filepath: z.string().describe('File path within the product (e.g., "docs/research/user-research.md", "meta-ads.json", "brand-assets/logo.png")'),
12
17
  });
13
18
  export const updateProductKnowledgeSchema = z.object({
14
19
  slug: z.string().describe('Product slug'),
15
- filepath: z.string().describe('Knowledge file to update (e.g., "meta-ads.json", "brand.json", "product.json", "copy.json", "cpps.json")'),
20
+ filepath: z.string().describe('Config file to update (e.g., "meta-ads.json", "cpps.json", "brand-config.json")'),
16
21
  content: z.record(z.unknown()).describe('JSON object to deep merge-patch into the knowledge file. Use null values to delete keys.'),
17
22
  updated_by: z.string().optional().describe('Agent name or ID writing the update'),
18
23
  });
19
24
  export const uploadProductFilesSchema = z.object({
20
25
  slug: z.string().describe('Product slug'),
21
26
  files: z.array(z.object({
22
- path: z.string().describe('File path within the product (e.g., "brand-assets/logo.png")'),
27
+ path: z.string().describe('File path within the product (e.g., "docs/research/user-research.md", "brand-assets/logo.png")'),
23
28
  content: z.string().describe('File content as text or base64 data URI (e.g., "data:image/png;base64,...")'),
24
29
  content_type: z.string().optional().describe('MIME type (auto-detected if not provided)'),
25
30
  })).describe('Files to upload'),
@@ -27,18 +32,38 @@ export const uploadProductFilesSchema = z.object({
27
32
  export const pullProductFilesSchema = z.object({
28
33
  slug: z.string().describe('Product slug'),
29
34
  output_dir: z.string().describe('Local directory to write files to. Will be created if needed.'),
30
- files: z.array(z.string()).optional().describe('Specific file paths to pull. If omitted, all binary files are pulled.'),
35
+ files: z.array(z.string()).optional().describe('Specific file paths to pull (e.g., ["docs/research/roadmap.md", "brand-assets/logo.png"]). If omitted, all binary files are pulled.'),
36
+ include_knowledge: z.boolean().optional().describe('Include knowledge JSON files in addition to binary files. Default: false.'),
37
+ });
38
+ export const getProductIndexSchema = z.object({
39
+ slug: z.string().describe('Product slug'),
40
+ domain: z.enum(DOMAIN_VALUES).optional().describe('Filter index entries by domain'),
41
+ });
42
+ export const getProductHealthSchema = z.object({
43
+ slug: z.string().optional().describe('Product slug. If omitted, returns health for all products.'),
44
+ });
45
+ export const createProductSchema = z.object({
46
+ name: z.string().describe('Product name'),
47
+ slug: z.string().optional().describe('URL-friendly slug (auto-generated from name if not provided)'),
48
+ description: z.string().optional().describe('Product description'),
49
+ });
50
+ export const deleteProductSchema = z.object({
51
+ slug: z.string().describe('Product slug'),
52
+ });
53
+ export const deleteProductFileSchema = z.object({
54
+ slug: z.string().describe('Product slug'),
55
+ filepath: z.string().describe('File path within the product to delete (e.g., "docs/copy/old-brief.md", "brand-assets/old-logo.png")'),
31
56
  });
32
57
  // --- Tool factories ---
33
58
  export function createListProductsTool(client) {
34
- return async (_input) => {
35
- const result = await client.listProducts();
59
+ return async (input) => {
60
+ const result = await client.listProducts(input.search ? { search: input.search } : undefined);
36
61
  return result;
37
62
  };
38
63
  }
39
64
  export function createGetProductTool(client) {
40
65
  return async (input) => {
41
- const result = await client.getProduct(input.slug);
66
+ const result = await client.getProduct(input.slug, input.domain);
42
67
  return result;
43
68
  };
44
69
  }
@@ -64,13 +89,7 @@ export function createUploadProductFilesTool(client) {
64
89
  if (content.startsWith('/') || content.startsWith('./') || content.startsWith('../')) {
65
90
  const filePath = path.resolve(content);
66
91
  const buffer = await fs.readFile(filePath);
67
- const ext = path.extname(filePath).slice(1).toLowerCase();
68
- const mimeTypes = {
69
- png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
70
- gif: 'image/gif', webp: 'image/webp', pdf: 'application/pdf',
71
- mp4: 'video/mp4', mov: 'video/quicktime', svg: 'image/svg+xml',
72
- };
73
- const mimeType = file.content_type || mimeTypes[ext] || 'application/octet-stream';
92
+ const mimeType = file.content_type || getMimeType(filePath);
74
93
  const base64 = buffer.toString('base64');
75
94
  content = `data:${mimeType};base64,${base64}`;
76
95
  }
@@ -82,43 +101,69 @@ export function createUploadProductFilesTool(client) {
82
101
  }
83
102
  export function createPullProductFilesTool(client) {
84
103
  return async (input) => {
85
- const { product, binary_files } = await client.getProduct(input.slug);
86
- let filesToPull = binary_files.map((f) => f.path);
87
- if (input.files && input.files.length > 0) {
88
- filesToPull = input.files;
89
- }
90
- if (filesToPull.length === 0) {
91
- return { product: { name: product.name, slug: product.slug }, files_pulled: 0, output_dir: input.output_dir, files: [] };
104
+ const { files } = await client.pullProductFiles(input.slug, {
105
+ files: input.files,
106
+ include_knowledge: input.include_knowledge,
107
+ });
108
+ if (files.length === 0) {
109
+ return { files_pulled: 0, output_dir: input.output_dir, files: [] };
92
110
  }
93
111
  const outputDir = path.resolve(input.output_dir);
94
112
  await fs.mkdir(outputDir, { recursive: true });
95
113
  const written = [];
96
114
  const resolvedOutputDir = path.resolve(outputDir);
97
- for (const filePath of filesToPull) {
98
- const result = await client.getProductFile(input.slug, filePath);
99
- const destPath = path.join(outputDir, filePath);
115
+ for (const file of files) {
116
+ const destPath = path.join(outputDir, file.path);
100
117
  const resolvedDestPath = path.resolve(destPath);
101
118
  if (!resolvedDestPath.startsWith(resolvedOutputDir + path.sep) && resolvedDestPath !== resolvedOutputDir) {
102
- throw new Error(`Unsafe file path rejected: ${filePath}`);
119
+ throw new Error(`Unsafe file path rejected: ${file.path}`);
103
120
  }
104
121
  await fs.mkdir(path.dirname(destPath), { recursive: true });
105
- const content = result.content;
122
+ const content = file.content;
106
123
  if (content.startsWith('data:')) {
107
124
  const commaIndex = content.indexOf(',');
125
+ if (commaIndex === -1)
126
+ throw new Error(`Invalid data URI format for file: ${file.path}`);
108
127
  const base64Data = content.slice(commaIndex + 1);
109
128
  await fs.writeFile(destPath, Buffer.from(base64Data, 'base64'));
110
- written.push({ path: filePath, size: result.size || 0 });
111
129
  }
112
130
  else {
113
131
  await fs.writeFile(destPath, content, 'utf-8');
114
- written.push({ path: filePath, size: content.length });
115
132
  }
133
+ written.push({ path: file.path, size: file.size });
116
134
  }
117
135
  return {
118
- product: { name: product.name, slug: product.slug },
119
136
  files_pulled: written.length,
120
137
  output_dir: outputDir,
121
138
  files: written,
122
139
  };
123
140
  };
124
141
  }
142
+ export function createGetProductIndexTool(client) {
143
+ return async (input) => {
144
+ return client.getProductIndex(input.slug, input.domain);
145
+ };
146
+ }
147
+ export function createGetProductHealthTool(client) {
148
+ return async (input) => {
149
+ if (input.slug) {
150
+ return client.getProductHealth(input.slug);
151
+ }
152
+ return client.getAllProductsHealth();
153
+ };
154
+ }
155
+ export function createCreateProductTool(client) {
156
+ return async (input) => {
157
+ return client.createProduct(input);
158
+ };
159
+ }
160
+ export function createDeleteProductTool(client) {
161
+ return async (input) => {
162
+ return client.deleteProduct(input.slug);
163
+ };
164
+ }
165
+ export function createDeleteProductFileTool(client) {
166
+ return async (input) => {
167
+ return client.deleteProductFile(input.slug, input.filepath);
168
+ };
169
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "44reports-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "MCP server for deploying and pulling context files (code, docs, data) to/from 44reports",
6
6
  "main": "dist/index.js",