44reports-mcp 1.2.0 → 1.3.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.
@@ -16,7 +16,11 @@ export class ApiClient {
16
16
  });
17
17
  if (!response.ok) {
18
18
  const error = await response.json().catch(() => ({ error: response.statusText }));
19
- throw new Error(error.error || `HTTP ${response.status}`);
19
+ const message = error.error || `HTTP ${response.status}`;
20
+ if (response.status === 401) {
21
+ throw new Error(`${message}. Reporter token rejected. Generate or rotate yours at ${this.config.apiUrl.replace('reporter.44pixels.workers.dev', 'reporter-directory.pages.dev')} → Settings, then update REPORTER_API_KEY in your Claude config and restart Claude Code.`);
22
+ }
23
+ throw new Error(message);
20
24
  }
21
25
  return response.json();
22
26
  }
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { listReportsSchema, getReportSchema, deleteReportSchema, createListTool,
9
9
  import { pullContextSchema, createPullTool, } from './tools/pull.js';
10
10
  import { manageFoldersSchema, createManageFoldersTool, } from './tools/folders.js';
11
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 { createUploadProductDirectoryTool, uploadProductDirectorySchema, UPLOAD_DIRECTORY_DESCRIPTION, } from './tools/upload-directory.js';
12
13
  import { addToBacklogSchema, listBacklogSchema, processBacklogSchema, createAddToBacklogTool, createListBacklogTool, createProcessBacklogTool, } from './tools/backlog.js';
13
14
  const config = loadConfig();
14
15
  const client = new ApiClient(config);
@@ -196,7 +197,12 @@ const toolDefinitions = [
196
197
  },
197
198
  {
198
199
  name: 'upload_product_files',
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',
200
+ description: 'Upload one or more files to a product. For multiple files in a directory, prefer ' +
201
+ 'upload_product_directory.\n\n' +
202
+ 'Place files at media/<domain>/ for binaries, docs/<domain>/ for markdown:\n' +
203
+ ' media/brand/, media/product-research/, media/copy/, media/advertising/, media/app-store/\n' +
204
+ ' docs/brand/, docs/research/, docs/copy/, docs/advertising/, docs/app-store/\n\n' +
205
+ 'Pass content as a LOCAL PATH (preferred), a base64 data URI, or plain text.',
200
206
  inputSchema: {
201
207
  type: 'object',
202
208
  properties: {
@@ -218,6 +224,31 @@ const toolDefinitions = [
218
224
  schema: uploadProductFilesSchema,
219
225
  handler: createUploadProductFilesTool(client),
220
226
  },
227
+ {
228
+ name: 'upload_product_directory',
229
+ description: UPLOAD_DIRECTORY_DESCRIPTION,
230
+ inputSchema: {
231
+ type: 'object',
232
+ properties: {
233
+ slug: prop('string', 'Product slug'),
234
+ local_dir: prop('string', 'Absolute or relative path to the local directory to upload'),
235
+ domain: {
236
+ type: 'string',
237
+ enum: ['product-research', 'brand', 'copy', 'advertising', 'app-store'],
238
+ description: 'Product domain — determines target folder (media/<domain>/)',
239
+ },
240
+ dest_prefix: prop('string', 'Subpath under media/<domain>/. Defaults to basename of local_dir.'),
241
+ glob: prop('string', 'Include filter, e.g. "**/*.{png,jpg,mp4}". Default: "**/*".'),
242
+ exclude: prop('array', 'Exclude patterns. Default: [".*", "**/.*", "node_modules/**"].', {
243
+ items: { type: 'string' },
244
+ }),
245
+ dry_run: prop('boolean', 'If true, returns the planned uploads without sending. Default: false.'),
246
+ },
247
+ required: ['slug', 'local_dir', 'domain'],
248
+ },
249
+ schema: uploadProductDirectorySchema,
250
+ handler: createUploadProductDirectoryTool(client),
251
+ },
221
252
  {
222
253
  name: 'pull_product_files',
223
254
  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"]).',
@@ -0,0 +1,2 @@
1
+ export declare const DOMAIN_VALUES: readonly ["product-research", "brand", "copy", "advertising", "app-store"];
2
+ export type Domain = (typeof DOMAIN_VALUES)[number];
@@ -0,0 +1 @@
1
+ export const DOMAIN_VALUES = ['product-research', 'brand', 'copy', 'advertising', 'app-store'];
@@ -2,11 +2,11 @@ import { z } from 'zod';
2
2
  import * as fs from 'fs/promises';
3
3
  import * as path from 'path';
4
4
  import { getMimeType } from './deploy.js';
5
+ import { DOMAIN_VALUES } from './domains.js';
5
6
  // --- Schemas ---
6
7
  export const listProductsSchema = z.object({
7
8
  search: z.string().optional().describe('Search term to filter products by name or description'),
8
9
  });
9
- const DOMAIN_VALUES = ['product-research', 'brand', 'copy', 'advertising', 'app-store'];
10
10
  export const getProductSchema = z.object({
11
11
  slug: z.string().describe('Product slug'),
12
12
  domain: z.enum(DOMAIN_VALUES).optional().describe('Filter files by domain'),
@@ -25,7 +25,13 @@ export const uploadProductFilesSchema = z.object({
25
25
  slug: z.string().describe('Product slug'),
26
26
  files: z.array(z.object({
27
27
  path: z.string().describe('File path within the product (e.g., "docs/research/user-research.md", "brand-assets/logo.png")'),
28
- content: z.string().describe('File content as text or base64 data URI (e.g., "data:image/png;base64,...")'),
28
+ content: z
29
+ .string()
30
+ .describe('File content. Three input modes:\n' +
31
+ ' • LOCAL PATH (preferred): "/abs/path/to/logo.png" or "./relative/path"\n' +
32
+ ' • Base64 data URI: "data:image/png;base64,..."\n' +
33
+ ' • Plain text (for small text files)\n' +
34
+ 'Use a local path whenever possible — base64 inline blows up your context for any non-tiny file.'),
29
35
  content_type: z.string().optional().describe('MIME type (auto-detected if not provided)'),
30
36
  })).describe('Files to upload'),
31
37
  });
@@ -0,0 +1,91 @@
1
+ import { z } from 'zod';
2
+ import type { ApiClient } from '../api-client.js';
3
+ export declare const uploadProductDirectorySchema: z.ZodObject<{
4
+ slug: z.ZodString;
5
+ local_dir: z.ZodString;
6
+ domain: z.ZodEnum<["product-research", "brand", "copy", "advertising", "app-store"]>;
7
+ dest_prefix: z.ZodOptional<z.ZodString>;
8
+ glob: z.ZodOptional<z.ZodString>;
9
+ exclude: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
10
+ dry_run: z.ZodOptional<z.ZodBoolean>;
11
+ }, "strip", z.ZodTypeAny, {
12
+ slug: string;
13
+ domain: "product-research" | "brand" | "copy" | "advertising" | "app-store";
14
+ local_dir: string;
15
+ dry_run?: boolean | undefined;
16
+ dest_prefix?: string | undefined;
17
+ glob?: string | undefined;
18
+ exclude?: string[] | undefined;
19
+ }, {
20
+ slug: string;
21
+ domain: "product-research" | "brand" | "copy" | "advertising" | "app-store";
22
+ local_dir: string;
23
+ dry_run?: boolean | undefined;
24
+ dest_prefix?: string | undefined;
25
+ glob?: string | undefined;
26
+ exclude?: string[] | undefined;
27
+ }>;
28
+ export declare const UPLOAD_DIRECTORY_DESCRIPTION: string;
29
+ export interface FileEntry {
30
+ localPath: string;
31
+ relativePath: string;
32
+ size: number;
33
+ }
34
+ export interface DestinationEntry extends FileEntry {
35
+ destPath: string;
36
+ contentType: string;
37
+ }
38
+ export interface WalkOptions {
39
+ glob: string;
40
+ exclude: string[];
41
+ }
42
+ export declare const DEFAULT_EXCLUDES: string[];
43
+ export declare function walkDirectory(absDir: string, options: WalkOptions): Promise<FileEntry[]>;
44
+ export interface DestinationOptions {
45
+ domain: string;
46
+ destPrefix: string;
47
+ }
48
+ export declare function computeDestinations(entries: FileEntry[], options: DestinationOptions): DestinationEntry[];
49
+ export interface Sized {
50
+ size: number;
51
+ }
52
+ export interface BatchLimits {
53
+ maxFiles: number;
54
+ maxBytes: number;
55
+ }
56
+ export declare function batchByLimits<T extends Sized>(items: T[], limits: BatchLimits): T[][];
57
+ export declare function createUploadProductDirectoryTool(client: ApiClient): (input: z.infer<typeof uploadProductDirectorySchema>) => Promise<{
58
+ dry_run: boolean;
59
+ total_files: number;
60
+ total_bytes: number;
61
+ files: {
62
+ local_path: string;
63
+ dest_path: string;
64
+ size: number;
65
+ content_type: string;
66
+ }[];
67
+ skipped_too_large: {
68
+ local_path: string;
69
+ size: number;
70
+ limit: number;
71
+ }[];
72
+ uploaded?: undefined;
73
+ failed?: undefined;
74
+ } | {
75
+ dry_run: boolean;
76
+ total_files: number;
77
+ total_bytes: number;
78
+ uploaded: {
79
+ local_path: string;
80
+ dest_path: string;
81
+ size: number;
82
+ content_type: string;
83
+ }[];
84
+ failed: {
85
+ local_path: string;
86
+ dest_path: string;
87
+ error: string;
88
+ }[];
89
+ files?: undefined;
90
+ skipped_too_large?: undefined;
91
+ }>;
@@ -0,0 +1,191 @@
1
+ import { z } from 'zod';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import { minimatch } from 'minimatch';
6
+ import { getMimeType } from './deploy.js';
7
+ import { DOMAIN_VALUES } from './domains.js';
8
+ function expandHome(p) {
9
+ if (p === '~')
10
+ return os.homedir();
11
+ if (p.startsWith('~/'))
12
+ return path.join(os.homedir(), p.slice(2));
13
+ return p;
14
+ }
15
+ export const uploadProductDirectorySchema = z.object({
16
+ slug: z.string().describe('Product slug, e.g. "clara"'),
17
+ local_dir: z.string().describe('Absolute or relative path to the local directory to upload. ~/ is expanded to the home directory.'),
18
+ domain: z.enum(DOMAIN_VALUES).describe('Product domain — determines target folder (media/<domain>/)'),
19
+ dest_prefix: z
20
+ .string()
21
+ .optional()
22
+ .describe('Subpath under media/<domain>/. Defaults to the basename of local_dir.'),
23
+ glob: z.string().optional().describe('Include filter, e.g. "**/*.{png,jpg,mp4}". Default: "**/*".'),
24
+ exclude: z
25
+ .array(z.string())
26
+ .optional()
27
+ .describe('Exclude patterns. Default: [".*", "**/.*", "node_modules/**"].'),
28
+ dry_run: z.boolean().optional().describe('If true, return the planned uploads without sending. Default: false.'),
29
+ });
30
+ export const UPLOAD_DIRECTORY_DESCRIPTION = 'Upload an entire directory of files (images, videos, PDFs) into a product\'s media folder. ' +
31
+ 'Preferred over upload_product_files when you have multiple files on disk.\n\n' +
32
+ 'Files land at media/<domain>/<dest_prefix>/<relative-path>. dest_prefix defaults to the basename of local_dir.\n\n' +
33
+ 'Example: upload_product_directory({slug:"clara", local_dir:"./screenshots", domain:"product-research", dest_prefix:"ux-screenshots"}) ' +
34
+ '→ files at clara/media/product-research/ux-screenshots/*\n\n' +
35
+ 'Limits: 10MB per file (oversized files are reported as failed, not blocking). Auto-batches into chunks ≤90MB to stay under server limits. Total upload size is unbounded.';
36
+ export const DEFAULT_EXCLUDES = ['.*', '**/.*', 'node_modules/**'];
37
+ export async function walkDirectory(absDir, options) {
38
+ const entries = [];
39
+ async function recurse(currentAbs, currentRel) {
40
+ const items = await fs.readdir(currentAbs, { withFileTypes: true });
41
+ for (const item of items) {
42
+ const itemAbs = path.join(currentAbs, item.name);
43
+ const itemRel = currentRel ? `${currentRel}/${item.name}` : item.name;
44
+ if (options.exclude.some((pattern) => minimatch(itemRel, pattern, { dot: true })))
45
+ continue;
46
+ if (item.isDirectory()) {
47
+ await recurse(itemAbs, itemRel);
48
+ }
49
+ else if (item.isFile()) {
50
+ if (!minimatch(itemRel, options.glob, { dot: true }))
51
+ continue;
52
+ const stat = await fs.stat(itemAbs);
53
+ entries.push({ localPath: itemAbs, relativePath: itemRel, size: stat.size });
54
+ }
55
+ }
56
+ }
57
+ await recurse(absDir, '');
58
+ return entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
59
+ }
60
+ export function computeDestinations(entries, options) {
61
+ const prefix = options.destPrefix
62
+ ? `media/${options.domain}/${options.destPrefix}`
63
+ : `media/${options.domain}`;
64
+ return entries.map((e) => {
65
+ if (e.relativePath.split('/').includes('..')) {
66
+ throw new Error(`refusing to compute destination for path containing "..": ${e.relativePath}`);
67
+ }
68
+ return {
69
+ ...e,
70
+ destPath: `${prefix}/${e.relativePath}`,
71
+ contentType: getMimeType(e.localPath),
72
+ };
73
+ });
74
+ }
75
+ export function batchByLimits(items, limits) {
76
+ const batches = [];
77
+ let currentBatch = [];
78
+ let currentBytes = 0;
79
+ for (const item of items) {
80
+ if (currentBatch.length >= limits.maxFiles ||
81
+ (currentBatch.length > 0 && currentBytes + item.size > limits.maxBytes)) {
82
+ batches.push(currentBatch);
83
+ currentBatch = [];
84
+ currentBytes = 0;
85
+ }
86
+ currentBatch.push(item);
87
+ currentBytes += item.size;
88
+ }
89
+ if (currentBatch.length > 0)
90
+ batches.push(currentBatch);
91
+ return batches;
92
+ }
93
+ const PER_FILE_LIMIT = 10 * 1024 * 1024;
94
+ const BATCH_BYTES = 90 * 1024 * 1024;
95
+ const BATCH_FILES = 95;
96
+ export function createUploadProductDirectoryTool(client) {
97
+ return async (input) => {
98
+ const absDir = path.resolve(expandHome(input.local_dir));
99
+ const stat = await fs.stat(absDir).catch(() => null);
100
+ if (!stat || !stat.isDirectory()) {
101
+ throw new Error(`local_dir not found or not a directory: ${absDir}`);
102
+ }
103
+ const destPrefix = input.dest_prefix ?? path.basename(absDir);
104
+ const glob = input.glob ?? '**/*';
105
+ const exclude = input.exclude ?? DEFAULT_EXCLUDES;
106
+ const entries = await walkDirectory(absDir, { glob, exclude });
107
+ const destinations = computeDestinations(entries, { domain: input.domain, destPrefix });
108
+ const oversized = destinations.filter((d) => d.size > PER_FILE_LIMIT);
109
+ const sized = destinations.filter((d) => d.size <= PER_FILE_LIMIT);
110
+ const totalBytes = sized.reduce((sum, d) => sum + d.size, 0);
111
+ if (input.dry_run) {
112
+ return {
113
+ dry_run: true,
114
+ total_files: destinations.length,
115
+ total_bytes: totalBytes,
116
+ files: sized.map((d) => ({
117
+ local_path: d.localPath,
118
+ dest_path: d.destPath,
119
+ size: d.size,
120
+ content_type: d.contentType,
121
+ })),
122
+ skipped_too_large: oversized.map((d) => ({
123
+ local_path: d.localPath,
124
+ size: d.size,
125
+ limit: PER_FILE_LIMIT,
126
+ })),
127
+ };
128
+ }
129
+ const batches = batchByLimits(sized, { maxFiles: BATCH_FILES, maxBytes: BATCH_BYTES });
130
+ const uploaded = [];
131
+ const failed = oversized.map((d) => ({
132
+ local_path: d.localPath,
133
+ dest_path: d.destPath,
134
+ error: `exceeds per-file limit (${(d.size / 1024 / 1024).toFixed(1)}MB > ${PER_FILE_LIMIT / 1024 / 1024}MB)`,
135
+ }));
136
+ for (const batch of batches) {
137
+ const reads = await Promise.all(batch.map(async (d) => {
138
+ try {
139
+ const base64 = await fs.readFile(d.localPath, { encoding: 'base64' });
140
+ return {
141
+ ok: true,
142
+ dest: d,
143
+ file: {
144
+ path: d.destPath,
145
+ content: `data:${d.contentType};base64,${base64}`,
146
+ content_type: d.contentType,
147
+ },
148
+ };
149
+ }
150
+ catch (e) {
151
+ return {
152
+ ok: false,
153
+ dest: d,
154
+ error: e instanceof Error ? e.message : String(e),
155
+ };
156
+ }
157
+ }));
158
+ for (const r of reads) {
159
+ if (!r.ok)
160
+ failed.push({ local_path: r.dest.localPath, dest_path: r.dest.destPath, error: r.error });
161
+ }
162
+ const successfulReads = reads.filter((r) => r.ok);
163
+ if (successfulReads.length === 0)
164
+ continue;
165
+ try {
166
+ await client.uploadProductFiles(input.slug, successfulReads.map((r) => r.file));
167
+ for (const r of successfulReads) {
168
+ uploaded.push({
169
+ local_path: r.dest.localPath,
170
+ dest_path: r.dest.destPath,
171
+ size: r.dest.size,
172
+ content_type: r.dest.contentType,
173
+ });
174
+ }
175
+ }
176
+ catch (e) {
177
+ const error = e instanceof Error ? e.message : String(e);
178
+ for (const r of successfulReads) {
179
+ failed.push({ local_path: r.dest.localPath, dest_path: r.dest.destPath, error });
180
+ }
181
+ }
182
+ }
183
+ return {
184
+ dry_run: false,
185
+ total_files: destinations.length,
186
+ total_bytes: totalBytes,
187
+ uploaded,
188
+ failed,
189
+ };
190
+ };
191
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,173 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import { walkDirectory, computeDestinations, batchByLimits, } from './upload-directory.js';
5
+ async function makeTempDir(structure) {
6
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'upload-test-'));
7
+ for (const [rel, content] of Object.entries(structure)) {
8
+ const abs = path.join(dir, rel);
9
+ await fs.mkdir(path.dirname(abs), { recursive: true });
10
+ await fs.writeFile(abs, content);
11
+ }
12
+ return dir;
13
+ }
14
+ describe('walkDirectory', () => {
15
+ it('returns absolute paths and sizes for regular files', async () => {
16
+ const dir = await makeTempDir({
17
+ 'a.txt': 'hello',
18
+ 'sub/b.png': Buffer.alloc(10),
19
+ });
20
+ const entries = await walkDirectory(dir, { glob: '**/*', exclude: [] });
21
+ expect(entries).toHaveLength(2);
22
+ const a = entries.find((e) => e.localPath.endsWith('a.txt'));
23
+ expect(a.size).toBe(5);
24
+ expect(a.relativePath).toBe('a.txt');
25
+ const b = entries.find((e) => e.localPath.endsWith('b.png'));
26
+ expect(b.size).toBe(10);
27
+ expect(b.relativePath).toBe('sub/b.png');
28
+ });
29
+ it('applies glob filtering', async () => {
30
+ const dir = await makeTempDir({
31
+ 'logo.png': Buffer.alloc(1),
32
+ 'README.md': 'doc',
33
+ 'sub/icon.svg': '<svg/>',
34
+ });
35
+ const entries = await walkDirectory(dir, { glob: '**/*.{png,svg}', exclude: [] });
36
+ expect(entries.map((e) => e.relativePath).sort()).toEqual(['logo.png', 'sub/icon.svg']);
37
+ });
38
+ it('applies excludes', async () => {
39
+ const dir = await makeTempDir({
40
+ 'logo.png': Buffer.alloc(1),
41
+ '.DS_Store': Buffer.alloc(1),
42
+ 'node_modules/x.js': '',
43
+ });
44
+ const entries = await walkDirectory(dir, {
45
+ glob: '**/*',
46
+ exclude: ['.*', '**/.*', 'node_modules/**'],
47
+ });
48
+ expect(entries.map((e) => e.relativePath)).toEqual(['logo.png']);
49
+ });
50
+ });
51
+ describe('computeDestinations', () => {
52
+ const entry = (relativePath, size = 1) => ({
53
+ localPath: '/tmp/x/' + relativePath,
54
+ relativePath,
55
+ size,
56
+ });
57
+ it('places files under media/<domain>/<destPrefix>/', () => {
58
+ const dest = computeDestinations([entry('logo.png'), entry('sub/icon.svg')], { domain: 'brand', destPrefix: 'logos' });
59
+ expect(dest.map((d) => d.destPath)).toEqual([
60
+ 'media/brand/logos/logo.png',
61
+ 'media/brand/logos/sub/icon.svg',
62
+ ]);
63
+ });
64
+ it('omits destPrefix when empty', () => {
65
+ const dest = computeDestinations([entry('logo.png')], { domain: 'brand', destPrefix: '' });
66
+ expect(dest[0].destPath).toBe('media/brand/logo.png');
67
+ });
68
+ it('attaches mime type via getMimeType', () => {
69
+ const dest = computeDestinations([entry('logo.png')], { domain: 'brand', destPrefix: '' });
70
+ expect(dest[0].contentType).toBe('image/png');
71
+ });
72
+ it('rejects relative paths containing ..', () => {
73
+ expect(() => computeDestinations([entry('../escape.png')], { domain: 'brand', destPrefix: '' })).toThrow(/containing ".."/);
74
+ });
75
+ });
76
+ describe('batchByLimits', () => {
77
+ const item = (size) => ({ size, dummy: true });
78
+ it('groups items so each batch is <= maxFiles and <= maxBytes', () => {
79
+ const items = [item(40 * 1024 * 1024), item(40 * 1024 * 1024), item(20 * 1024 * 1024)];
80
+ const batches = batchByLimits(items, { maxFiles: 100, maxBytes: 90 * 1024 * 1024 });
81
+ expect(batches.length).toBe(2);
82
+ expect(batches[0].length).toBe(2);
83
+ expect(batches[1].length).toBe(1);
84
+ });
85
+ it('starts a new batch when adding the next file would exceed maxBytes', () => {
86
+ const items = [item(80 * 1024 * 1024), item(20 * 1024 * 1024)];
87
+ const batches = batchByLimits(items, { maxFiles: 100, maxBytes: 90 * 1024 * 1024 });
88
+ expect(batches.length).toBe(2);
89
+ });
90
+ it('respects maxFiles even with small files', () => {
91
+ const items = Array.from({ length: 200 }, () => item(1024));
92
+ const batches = batchByLimits(items, { maxFiles: 95, maxBytes: 90 * 1024 * 1024 });
93
+ expect(batches.length).toBe(3);
94
+ expect(batches[0].length).toBe(95);
95
+ expect(batches[1].length).toBe(95);
96
+ expect(batches[2].length).toBe(10);
97
+ });
98
+ });
99
+ import { createUploadProductDirectoryTool } from './upload-directory.js';
100
+ function makeClient(shouldFail = false) {
101
+ const calls = [];
102
+ return {
103
+ calls,
104
+ shouldFail,
105
+ uploadProductFiles: async (slug, files) => {
106
+ calls.push({ slug, files: files.map((f) => ({ path: f.path, content_type: f.content_type })) });
107
+ if (shouldFail)
108
+ throw new Error('simulated server failure');
109
+ return { uploaded: files.map((f) => f.path) };
110
+ },
111
+ };
112
+ }
113
+ describe('createUploadProductDirectoryTool — dry_run', () => {
114
+ it('returns plan without invoking the client', async () => {
115
+ const dir = await makeTempDir({ 'a.png': Buffer.alloc(100), 'sub/b.png': Buffer.alloc(50) });
116
+ const client = makeClient();
117
+ const tool = createUploadProductDirectoryTool(client);
118
+ const result = await tool({
119
+ slug: 'clara',
120
+ local_dir: dir,
121
+ domain: 'brand',
122
+ dest_prefix: 'logos',
123
+ dry_run: true,
124
+ });
125
+ expect(result.dry_run).toBe(true);
126
+ expect(result.total_files).toBe(2);
127
+ expect(client.calls).toEqual([]);
128
+ });
129
+ });
130
+ describe('createUploadProductDirectoryTool — oversized', () => {
131
+ it('reports oversized files as failed and continues with the rest', async () => {
132
+ const dir = await makeTempDir({
133
+ 'small.png': Buffer.alloc(100),
134
+ 'huge.png': Buffer.alloc(11 * 1024 * 1024), // 11MB > 10MB limit
135
+ });
136
+ const client = makeClient();
137
+ const tool = createUploadProductDirectoryTool(client);
138
+ const result = await tool({
139
+ slug: 'clara',
140
+ local_dir: dir,
141
+ domain: 'brand',
142
+ dry_run: false,
143
+ });
144
+ expect(result.dry_run).toBe(false);
145
+ expect(result.uploaded.length).toBe(1);
146
+ expect(result.uploaded[0].dest_path).toMatch(/small\.png$/);
147
+ expect(result.failed.length).toBe(1);
148
+ expect(result.failed[0].error).toMatch(/exceeds per-file limit/);
149
+ });
150
+ });
151
+ describe('createUploadProductDirectoryTool — missing dir', () => {
152
+ it('throws a clear error when local_dir does not exist', async () => {
153
+ const client = makeClient();
154
+ const tool = createUploadProductDirectoryTool(client);
155
+ await expect(tool({ slug: 'clara', local_dir: '/nonexistent/path/zxqwrt', domain: 'brand' })).rejects.toThrow(/local_dir not found/);
156
+ });
157
+ });
158
+ describe('createUploadProductDirectoryTool — client failure', () => {
159
+ it('marks every file in the failing batch as failed', async () => {
160
+ const dir = await makeTempDir({ 'a.png': Buffer.alloc(100), 'b.png': Buffer.alloc(100) });
161
+ const client = makeClient(true);
162
+ const tool = createUploadProductDirectoryTool(client);
163
+ const result = await tool({
164
+ slug: 'clara',
165
+ local_dir: dir,
166
+ domain: 'brand',
167
+ dry_run: false,
168
+ });
169
+ expect(result.uploaded.length).toBe(0);
170
+ expect(result.failed.length).toBe(2);
171
+ expect(result.failed.every((f) => f.error.includes('simulated server failure'))).toBe(true);
172
+ });
173
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "44reports-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",
@@ -33,6 +33,7 @@
33
33
  "license": "MIT",
34
34
  "dependencies": {
35
35
  "@modelcontextprotocol/sdk": "^1.0.0",
36
+ "minimatch": "^10.2.5",
36
37
  "zod": "^3.23.8"
37
38
  },
38
39
  "devDependencies": {