44reports-mcp 1.0.7

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.
@@ -0,0 +1,79 @@
1
+ import type { Config } from './config.js';
2
+ export interface ReportFile {
3
+ path: string;
4
+ content: string;
5
+ content_type?: string;
6
+ }
7
+ export interface DeployRequest {
8
+ name: string;
9
+ slug?: string;
10
+ description?: string;
11
+ agent_id: string;
12
+ agent_name?: string;
13
+ entry_file?: string | null;
14
+ tags?: string[];
15
+ files: ReportFile[];
16
+ }
17
+ export interface UpdateRequest {
18
+ name?: string;
19
+ description?: string;
20
+ tags?: string[];
21
+ files?: ReportFile[];
22
+ }
23
+ export interface Report {
24
+ id: string;
25
+ slug: string;
26
+ name: string;
27
+ description: string | null;
28
+ agent_id: string;
29
+ agent_name: string | null;
30
+ created_at: string;
31
+ updated_at: string;
32
+ version: number;
33
+ entry_file: string | null;
34
+ file_count: number;
35
+ total_size_bytes: number;
36
+ tags: string[];
37
+ }
38
+ export interface FileInfo {
39
+ path: string;
40
+ size: number;
41
+ uploaded: string;
42
+ }
43
+ export interface PulledFile {
44
+ path: string;
45
+ content: string;
46
+ content_type: string;
47
+ size: number;
48
+ }
49
+ export declare class ApiClient {
50
+ private readonly config;
51
+ constructor(config: Config);
52
+ private request;
53
+ deployReport(request: DeployRequest): Promise<{
54
+ report: Report;
55
+ url: string;
56
+ }>;
57
+ updateReport(slug: string, request: UpdateRequest): Promise<{
58
+ report: Report;
59
+ url: string;
60
+ }>;
61
+ listReports(options?: {
62
+ agent_id?: string;
63
+ search?: string;
64
+ limit?: number;
65
+ offset?: number;
66
+ }): Promise<{
67
+ reports: Report[];
68
+ }>;
69
+ getReport(slug: string): Promise<{
70
+ report: Report;
71
+ files: FileInfo[];
72
+ }>;
73
+ deleteReport(slug: string): Promise<{
74
+ success: boolean;
75
+ }>;
76
+ pullContextFiles(slug: string, filePaths?: string[]): Promise<{
77
+ files: PulledFile[];
78
+ }>;
79
+ }
@@ -0,0 +1,59 @@
1
+ export class ApiClient {
2
+ config;
3
+ constructor(config) {
4
+ this.config = config;
5
+ }
6
+ async request(path, options = {}) {
7
+ const url = `${this.config.apiUrl}${path}`;
8
+ const headers = new Headers(options.headers);
9
+ headers.set('X-API-Key', this.config.apiKey);
10
+ if (options.body) {
11
+ headers.set('Content-Type', 'application/json');
12
+ }
13
+ const response = await fetch(url, {
14
+ ...options,
15
+ headers,
16
+ });
17
+ if (!response.ok) {
18
+ const error = await response.json().catch(() => ({ error: response.statusText }));
19
+ throw new Error(error.error || `HTTP ${response.status}`);
20
+ }
21
+ return response.json();
22
+ }
23
+ async deployReport(request) {
24
+ return this.request('/api/deploy', {
25
+ method: 'POST',
26
+ body: JSON.stringify(request),
27
+ });
28
+ }
29
+ async updateReport(slug, request) {
30
+ return this.request(`/api/deploy/${slug}`, {
31
+ method: 'PUT',
32
+ body: JSON.stringify(request),
33
+ });
34
+ }
35
+ async listReports(options) {
36
+ const params = new URLSearchParams();
37
+ for (const [key, value] of Object.entries(options ?? {})) {
38
+ if (value !== undefined) {
39
+ params.set(key, String(value));
40
+ }
41
+ }
42
+ const query = params.toString();
43
+ return this.request(`/api/reports${query ? `?${query}` : ''}`);
44
+ }
45
+ async getReport(slug) {
46
+ return this.request(`/api/reports/${slug}`);
47
+ }
48
+ async deleteReport(slug) {
49
+ return this.request(`/api/reports/${slug}`, {
50
+ method: 'DELETE',
51
+ });
52
+ }
53
+ async pullContextFiles(slug, filePaths) {
54
+ return this.request(`/api/reports/${slug}/pull`, {
55
+ method: 'POST',
56
+ body: JSON.stringify({ files: filePaths }),
57
+ });
58
+ }
59
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ declare const ConfigSchema: z.ZodObject<{
3
+ apiKey: z.ZodString;
4
+ apiUrl: z.ZodString;
5
+ agentId: z.ZodOptional<z.ZodString>;
6
+ agentName: z.ZodOptional<z.ZodString>;
7
+ }, "strip", z.ZodTypeAny, {
8
+ apiKey: string;
9
+ apiUrl: string;
10
+ agentId?: string | undefined;
11
+ agentName?: string | undefined;
12
+ }, {
13
+ apiKey: string;
14
+ apiUrl: string;
15
+ agentId?: string | undefined;
16
+ agentName?: string | undefined;
17
+ }>;
18
+ export type Config = z.infer<typeof ConfigSchema>;
19
+ export declare function loadConfig(): Config;
20
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ const ConfigSchema = z.object({
3
+ apiKey: z.string().min(1),
4
+ apiUrl: z.string().url(),
5
+ agentId: z.string().optional(),
6
+ agentName: z.string().optional(),
7
+ });
8
+ export function loadConfig() {
9
+ const config = {
10
+ apiKey: process.env.REPORTER_API_KEY || '',
11
+ apiUrl: process.env.REPORTER_API_URL || '',
12
+ agentId: process.env.AGENT_ID || '',
13
+ agentName: process.env.AGENT_NAME,
14
+ };
15
+ const result = ConfigSchema.safeParse(config);
16
+ if (!result.success) {
17
+ const missing = [];
18
+ if (!config.apiKey)
19
+ missing.push('REPORTER_API_KEY');
20
+ if (!config.apiUrl)
21
+ missing.push('REPORTER_API_URL');
22
+ console.error(`
23
+ ╔═══════════════════════════════════════════════════════════════════╗
24
+ ║ Reporter MCP Configuration ║
25
+ ╠═══════════════════════════════════════════════════════════════════╣
26
+ ║ Missing required environment variables: ║
27
+ ${missing.map(v => `║ - ${v}${' '.repeat(61 - v.length)}║`).join('\n')}
28
+ ║ ║
29
+ ║ Add to ~/.claude/settings.json under "mcpServers": ║
30
+ ║ ║
31
+ ║ "reporter": { ║
32
+ ║ "command": "npx", ║
33
+ ║ "args": ["reporter-mcp"], ║
34
+ ║ "env": { ║
35
+ ║ "REPORTER_API_KEY": "your-api-key", ║
36
+ ║ "REPORTER_API_URL": "https://reporter.44pixels.workers.dev" ║
37
+ ║ } ║
38
+ ║ } ║
39
+ ║ ║
40
+ ║ Get your API key at: https://reporter-directory.pages.dev ║
41
+ ╚═══════════════════════════════════════════════════════════════════╝
42
+ `);
43
+ process.exit(1);
44
+ }
45
+ return result.data;
46
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
+ import { loadConfig } from './config.js';
6
+ import { ApiClient } from './api-client.js';
7
+ import { deployReportSchema, updateReportSchema, createDeployTool, createUpdateTool, } from './tools/deploy.js';
8
+ import { listReportsSchema, getReportSchema, deleteReportSchema, createListTool, createGetTool, createDeleteTool, } from './tools/reports.js';
9
+ import { pullContextSchema, createPullTool, } from './tools/pull.js';
10
+ const config = loadConfig();
11
+ const client = new ApiClient(config);
12
+ function prop(type, description, extra = {}) {
13
+ return { type, description, ...extra };
14
+ }
15
+ const CONTENT_DESC = 'Text content string for single-file deploys. For contexts with multiple files or files larger than a few KB, use folder_path instead.';
16
+ const FOLDER_PATH_DESC = 'RECOMMENDED for most contexts. Uploads all supported files in folder (code, docs, data, images, etc). Supports up to 100MB with 1000 files.';
17
+ const toolDefinitions = [
18
+ {
19
+ name: 'deploy_context',
20
+ description: 'Deploy files to the context repository. Supports any text-based files (code, docs, data, config) and binary files (images, fonts). For simple single-file contexts, use content. For multi-file contexts, use folder_path (recommended). Returns a stable URL for viewing and sharing.',
21
+ inputSchema: {
22
+ type: 'object',
23
+ properties: {
24
+ name: prop('string', 'Name of the context'),
25
+ slug: prop('string', 'URL-friendly identifier (auto-generated if not provided)'),
26
+ description: prop('string', 'Description of the context'),
27
+ content: prop('string', CONTENT_DESC),
28
+ content_filename: prop('string', 'Filename for single-content deploys (e.g., "README.md", "data.csv"). Auto-detected if not provided.'),
29
+ folder_path: prop('string', FOLDER_PATH_DESC),
30
+ entry_file: prop('string', 'Main file served at the context URL. Auto-detected if not provided (prefers index.html, then README.md). Other files accessible via /r/{slug}/{filename}'),
31
+ tags: prop('array', 'Tags for categorization', { items: { type: 'string' } }),
32
+ agent_id: prop('string', 'Unique identifier for the agent deploying this context (e.g., "research-agent", "data-analyzer")'),
33
+ agent_name: prop('string', 'Human-readable name of the agent (e.g., "Research Agent", "Data Analyzer")'),
34
+ },
35
+ required: ['name', 'agent_id', 'agent_name'],
36
+ },
37
+ schema: deployReportSchema,
38
+ handler: createDeployTool(client, config),
39
+ },
40
+ {
41
+ name: 'update_context',
42
+ description: 'Update an existing context. The URL remains stable. You can update metadata, content, or both.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ slug: prop('string', 'Slug of the context to update'),
47
+ name: prop('string', 'New name for the context'),
48
+ description: prop('string', 'New description'),
49
+ content: prop('string', CONTENT_DESC),
50
+ content_filename: prop('string', 'Filename for single-content updates (e.g., "README.md"). Auto-detected if not provided.'),
51
+ folder_path: prop('string', FOLDER_PATH_DESC),
52
+ tags: prop('array', 'New tags', { items: { type: 'string' } }),
53
+ },
54
+ required: ['slug'],
55
+ },
56
+ schema: updateReportSchema,
57
+ handler: createUpdateTool(client),
58
+ },
59
+ {
60
+ name: 'list_contexts',
61
+ description: 'List all contexts in the repository, optionally filtered by agent ID or search terms.',
62
+ inputSchema: {
63
+ type: 'object',
64
+ properties: {
65
+ agent_id: prop('string', 'Filter by agent ID'),
66
+ search: prop('string', 'Search in name, description, and tags'),
67
+ limit: prop('number', 'Maximum number of results (default: 50)'),
68
+ offset: prop('number', 'Offset for pagination'),
69
+ },
70
+ },
71
+ schema: listReportsSchema,
72
+ handler: createListTool(client),
73
+ },
74
+ {
75
+ name: 'get_context',
76
+ description: 'Get detailed information about a specific context, including file list.',
77
+ inputSchema: {
78
+ type: 'object',
79
+ properties: {
80
+ slug: prop('string', 'Slug of the context to retrieve'),
81
+ },
82
+ required: ['slug'],
83
+ },
84
+ schema: getReportSchema,
85
+ handler: createGetTool(client),
86
+ },
87
+ {
88
+ name: 'delete_context',
89
+ description: 'Delete a context and all its associated files from the repository.',
90
+ inputSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ slug: prop('string', 'Slug of the context to delete'),
94
+ },
95
+ required: ['slug'],
96
+ },
97
+ schema: deleteReportSchema,
98
+ handler: createDeleteTool(client),
99
+ },
100
+ {
101
+ name: 'pull_context',
102
+ description: 'Pull files from a context repository to a local directory. Accepts a slug or full URL. Writes files to disk preserving directory structure. By default pulls only text files; set include_binary to true for images and other binary files.',
103
+ inputSchema: {
104
+ type: 'object',
105
+ properties: {
106
+ slug: prop('string', 'Slug or full URL of the context (e.g., "my-context" or "https://reporter.44pixels.workers.dev/r/my-context/")'),
107
+ output_dir: prop('string', 'Local directory to write files to. Will be created if it does not exist.'),
108
+ files: prop('array', 'Specific files to pull. If omitted, all text files are pulled.', { items: { type: 'string' } }),
109
+ include_binary: prop('boolean', 'Include binary files (images, fonts, etc). Default: false.'),
110
+ },
111
+ required: ['slug', 'output_dir'],
112
+ },
113
+ schema: pullContextSchema,
114
+ handler: createPullTool(client),
115
+ },
116
+ ];
117
+ const toolHandlers = Object.fromEntries(toolDefinitions.map((t) => [t.name, { schema: t.schema, handler: t.handler }]));
118
+ const server = new Server({ name: '44reports', version: '1.0.0' }, { capabilities: { tools: {} } });
119
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
120
+ tools: toolDefinitions.map(({ name, description, inputSchema }) => ({
121
+ name,
122
+ description,
123
+ inputSchema,
124
+ })),
125
+ }));
126
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
127
+ const { name, arguments: args } = request.params;
128
+ try {
129
+ const tool = toolHandlers[name];
130
+ if (!tool) {
131
+ throw new Error(`Unknown tool: ${name}`);
132
+ }
133
+ const parsedArgs = tool.schema.parse(args ?? {});
134
+ const result = await tool.handler(parsedArgs);
135
+ return {
136
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
137
+ };
138
+ }
139
+ catch (error) {
140
+ const message = error instanceof Error ? error.message : 'Unknown error';
141
+ return {
142
+ content: [{ type: 'text', text: JSON.stringify({ error: message }) }],
143
+ isError: true,
144
+ };
145
+ }
146
+ });
147
+ async function main() {
148
+ const transport = new StdioServerTransport();
149
+ await server.connect(transport);
150
+ console.error('44reports MCP server running');
151
+ }
152
+ main().catch((error) => {
153
+ console.error('Fatal error:', error);
154
+ process.exit(1);
155
+ });
@@ -0,0 +1,85 @@
1
+ import { z } from 'zod';
2
+ import type { ApiClient } from '../api-client.js';
3
+ import type { Config } from '../config.js';
4
+ export declare const deployReportSchema: z.ZodObject<{
5
+ name: z.ZodString;
6
+ slug: z.ZodOptional<z.ZodString>;
7
+ description: z.ZodOptional<z.ZodString>;
8
+ content: z.ZodOptional<z.ZodString>;
9
+ content_filename: z.ZodOptional<z.ZodString>;
10
+ folder_path: z.ZodOptional<z.ZodString>;
11
+ entry_file: z.ZodOptional<z.ZodString>;
12
+ tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
13
+ agent_id: z.ZodOptional<z.ZodString>;
14
+ agent_name: z.ZodOptional<z.ZodString>;
15
+ }, "strip", z.ZodTypeAny, {
16
+ name: string;
17
+ agent_id?: string | undefined;
18
+ slug?: string | undefined;
19
+ description?: string | undefined;
20
+ content?: string | undefined;
21
+ content_filename?: string | undefined;
22
+ folder_path?: string | undefined;
23
+ entry_file?: string | undefined;
24
+ tags?: string[] | undefined;
25
+ agent_name?: string | undefined;
26
+ }, {
27
+ name: string;
28
+ agent_id?: string | undefined;
29
+ slug?: string | undefined;
30
+ description?: string | undefined;
31
+ content?: string | undefined;
32
+ content_filename?: string | undefined;
33
+ folder_path?: string | undefined;
34
+ entry_file?: string | undefined;
35
+ tags?: string[] | undefined;
36
+ agent_name?: string | undefined;
37
+ }>;
38
+ export declare const updateReportSchema: z.ZodObject<{
39
+ slug: z.ZodString;
40
+ name: z.ZodOptional<z.ZodString>;
41
+ description: z.ZodOptional<z.ZodString>;
42
+ content: z.ZodOptional<z.ZodString>;
43
+ content_filename: z.ZodOptional<z.ZodString>;
44
+ folder_path: z.ZodOptional<z.ZodString>;
45
+ tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
46
+ }, "strip", z.ZodTypeAny, {
47
+ slug: string;
48
+ name?: string | undefined;
49
+ description?: string | undefined;
50
+ content?: string | undefined;
51
+ content_filename?: string | undefined;
52
+ folder_path?: string | undefined;
53
+ tags?: string[] | undefined;
54
+ }, {
55
+ slug: string;
56
+ name?: string | undefined;
57
+ description?: string | undefined;
58
+ content?: string | undefined;
59
+ content_filename?: string | undefined;
60
+ folder_path?: string | undefined;
61
+ tags?: string[] | undefined;
62
+ }>;
63
+ export declare function getMimeType(filePath: string): string;
64
+ export declare function shouldIgnoreFile(name: string): boolean;
65
+ export declare function autoDetectEntryFile(files: {
66
+ path: string;
67
+ }[]): string | null;
68
+ export declare function createDeployTool(client: ApiClient, config: Config): (input: z.infer<typeof deployReportSchema>) => Promise<{
69
+ success: boolean;
70
+ report: import("../api-client.js").Report;
71
+ url: string;
72
+ files_uploaded: number;
73
+ total_size_bytes: number;
74
+ total_size_formatted: string;
75
+ message: string;
76
+ }>;
77
+ export declare function createUpdateTool(client: ApiClient): (input: z.infer<typeof updateReportSchema>) => Promise<{
78
+ success: boolean;
79
+ report: import("../api-client.js").Report;
80
+ url: string;
81
+ files_uploaded: number;
82
+ total_size_bytes: number;
83
+ total_size_formatted: string;
84
+ message: string;
85
+ }>;
@@ -0,0 +1,285 @@
1
+ import { z } from 'zod';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ // Limits (must match server-side limits)
5
+ const MAX_FILES = 1000;
6
+ const MAX_TOTAL_SIZE = 100 * 1024 * 1024; // 100MB
7
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB per file
8
+ // Non-hidden system files to ignore when reading folders
9
+ const IGNORED_FILES = new Set([
10
+ 'Thumbs.db',
11
+ 'desktop.ini',
12
+ ]);
13
+ // Extension-to-MIME mapping (also serves as the set of allowed extensions)
14
+ const MIME_TYPES = {
15
+ // Web
16
+ '.html': 'text/html',
17
+ '.htm': 'text/html',
18
+ '.css': 'text/css',
19
+ '.js': 'application/javascript',
20
+ '.json': 'application/json',
21
+ // Images & fonts
22
+ '.png': 'image/png',
23
+ '.jpg': 'image/jpeg',
24
+ '.jpeg': 'image/jpeg',
25
+ '.gif': 'image/gif',
26
+ '.svg': 'image/svg+xml',
27
+ '.webp': 'image/webp',
28
+ '.ico': 'image/x-icon',
29
+ '.woff': 'font/woff',
30
+ '.woff2': 'font/woff2',
31
+ '.ttf': 'font/ttf',
32
+ '.eot': 'application/vnd.ms-fontobject',
33
+ '.pdf': 'application/pdf',
34
+ // Text & documentation
35
+ '.md': 'text/markdown',
36
+ '.txt': 'text/plain',
37
+ '.rst': 'text/x-rst',
38
+ '.log': 'text/plain',
39
+ // Code
40
+ '.ts': 'text/typescript',
41
+ '.tsx': 'text/typescript',
42
+ '.jsx': 'text/javascript',
43
+ '.py': 'text/x-python',
44
+ '.sh': 'text/x-shellscript',
45
+ '.bash': 'text/x-shellscript',
46
+ '.mjs': 'application/javascript',
47
+ '.cjs': 'application/javascript',
48
+ '.r': 'text/x-r',
49
+ '.rb': 'text/x-ruby',
50
+ '.go': 'text/x-go',
51
+ '.rs': 'text/x-rust',
52
+ '.java': 'text/x-java',
53
+ '.c': 'text/x-c',
54
+ '.cpp': 'text/x-c++',
55
+ '.h': 'text/x-c',
56
+ // Config & data
57
+ '.yaml': 'text/yaml',
58
+ '.yml': 'text/yaml',
59
+ '.toml': 'text/toml',
60
+ '.csv': 'text/csv',
61
+ '.tsv': 'text/tab-separated-values',
62
+ '.xml': 'application/xml',
63
+ '.sql': 'text/x-sql',
64
+ '.ini': 'text/plain',
65
+ '.cfg': 'text/plain',
66
+ '.conf': 'text/plain',
67
+ // Notebooks
68
+ '.ipynb': 'application/x-ipynb+json',
69
+ };
70
+ export const deployReportSchema = z.object({
71
+ name: z.string().describe('Name of the context'),
72
+ slug: z.string().optional().describe('URL-friendly identifier (auto-generated if not provided)'),
73
+ description: z.string().optional().describe('Description of the context'),
74
+ content: z.string().optional().describe('Text content string for single-file deploys. Use folder_path for multi-file contexts or files larger than a few KB.'),
75
+ content_filename: z.string().optional().describe('Filename for single-content deploys (e.g., "README.md", "data.csv"). Auto-detected if not provided.'),
76
+ folder_path: z.string().optional().describe('Absolute path to folder containing context files. All supported files in the folder will be uploaded. Recommended for most contexts.'),
77
+ entry_file: z.string().optional().describe('The main file to serve at the context URL. Auto-detected if not provided (prefers index.html, then README.md).'),
78
+ tags: z.array(z.string()).optional().describe('Tags for categorization'),
79
+ agent_id: z.string().optional().describe('Unique identifier for the agent deploying this context'),
80
+ agent_name: z.string().optional().describe('Human-readable name of the agent'),
81
+ });
82
+ export const updateReportSchema = z.object({
83
+ slug: z.string().describe('Slug of the context to update'),
84
+ name: z.string().optional().describe('New name for the context'),
85
+ description: z.string().optional().describe('New description'),
86
+ content: z.string().optional().describe('New content (for single-file update)'),
87
+ content_filename: z.string().optional().describe('Filename for single-content updates (e.g., "README.md"). Auto-detected if not provided.'),
88
+ folder_path: z.string().optional().describe('Path to folder with new files'),
89
+ tags: z.array(z.string()).optional().describe('New tags'),
90
+ });
91
+ export function getMimeType(filePath) {
92
+ const ext = path.extname(filePath).toLowerCase();
93
+ return MIME_TYPES[ext] || 'application/octet-stream';
94
+ }
95
+ const BINARY_EXTENSIONS = new Set([
96
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico',
97
+ '.woff', '.woff2', '.ttf', '.eot', '.pdf',
98
+ ]);
99
+ function isBinaryFile(filePath) {
100
+ const ext = path.extname(filePath).toLowerCase();
101
+ return BINARY_EXTENSIONS.has(ext);
102
+ }
103
+ async function verifyFolderExists(folderPath) {
104
+ const resolvedPath = path.resolve(folderPath);
105
+ try {
106
+ await fs.access(resolvedPath);
107
+ return resolvedPath;
108
+ }
109
+ catch {
110
+ throw new Error(`Folder not found: ${resolvedPath}`);
111
+ }
112
+ }
113
+ export function shouldIgnoreFile(name) {
114
+ if (IGNORED_FILES.has(name))
115
+ return true;
116
+ if (name.startsWith('.'))
117
+ return true;
118
+ if (name.endsWith('~'))
119
+ return true;
120
+ const ext = path.extname(name).toLowerCase();
121
+ return ext !== '' && !(ext in MIME_TYPES);
122
+ }
123
+ function formatSize(bytes) {
124
+ if (bytes > 1024 * 1024) {
125
+ return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
126
+ }
127
+ return `${(bytes / 1024).toFixed(1)}KB`;
128
+ }
129
+ function validateContentSize(content) {
130
+ const size = Buffer.byteLength(content, 'utf-8');
131
+ if (size > MAX_FILE_SIZE) {
132
+ throw new Error(`Content too large (${formatSize(size)}). Use folder_path instead for large files.`);
133
+ }
134
+ return size;
135
+ }
136
+ function detectContentFilename(content, explicitFilename) {
137
+ if (explicitFilename)
138
+ return explicitFilename;
139
+ const trimmed = content.trimStart();
140
+ if (trimmed.startsWith('<'))
141
+ return 'index.html';
142
+ return 'content.md';
143
+ }
144
+ function createSingleFileReport(content, filename) {
145
+ return [{
146
+ path: filename,
147
+ content,
148
+ content_type: getMimeType(filename),
149
+ }];
150
+ }
151
+ async function readFolderFiles(folderPath) {
152
+ const files = [];
153
+ let totalSize = 0;
154
+ async function readDir(dir, basePath = '') {
155
+ const entries = await fs.readdir(dir, { withFileTypes: true });
156
+ for (const entry of entries) {
157
+ if (shouldIgnoreFile(entry.name))
158
+ continue;
159
+ const fullPath = path.join(dir, entry.name);
160
+ const relativePath = path.join(basePath, entry.name);
161
+ if (entry.isDirectory()) {
162
+ await readDir(fullPath, relativePath);
163
+ continue;
164
+ }
165
+ if (!entry.isFile())
166
+ continue;
167
+ if (files.length >= MAX_FILES) {
168
+ throw new Error(`Too many files in folder (max ${MAX_FILES})`);
169
+ }
170
+ const stat = await fs.stat(fullPath);
171
+ if (stat.size > MAX_FILE_SIZE) {
172
+ throw new Error(`File too large: ${relativePath} (${formatSize(stat.size)}, max ${formatSize(MAX_FILE_SIZE)})`);
173
+ }
174
+ totalSize += stat.size;
175
+ if (totalSize > MAX_TOTAL_SIZE) {
176
+ throw new Error(`Total size exceeds limit (max ${formatSize(MAX_TOTAL_SIZE)})`);
177
+ }
178
+ const isBinary = isBinaryFile(fullPath);
179
+ const content = await fs.readFile(fullPath, isBinary ? 'base64' : 'utf-8');
180
+ const mimeType = getMimeType(fullPath);
181
+ files.push({
182
+ path: relativePath.replace(/\\/g, '/'),
183
+ content: isBinary ? `data:${mimeType};base64,${content}` : content,
184
+ content_type: mimeType,
185
+ });
186
+ }
187
+ }
188
+ await readDir(folderPath);
189
+ return { files, totalSize };
190
+ }
191
+ export function autoDetectEntryFile(files) {
192
+ const paths = files.map(f => f.path);
193
+ if (paths.includes('index.html'))
194
+ return 'index.html';
195
+ const htmlFiles = paths.filter(p => p.endsWith('.html') || p.endsWith('.htm'));
196
+ if (htmlFiles.length === 1)
197
+ return htmlFiles[0];
198
+ if (paths.includes('README.md'))
199
+ return 'README.md';
200
+ return null;
201
+ }
202
+ export function createDeployTool(client, config) {
203
+ return async (input) => {
204
+ if (!input.content && !input.folder_path) {
205
+ throw new Error('Either content or folder_path must be provided. Use folder_path for multi-file contexts or files larger than a few KB.');
206
+ }
207
+ let entryFile;
208
+ let files;
209
+ let totalSize;
210
+ if (input.folder_path) {
211
+ const resolvedPath = await verifyFolderExists(input.folder_path);
212
+ const result = await readFolderFiles(resolvedPath);
213
+ files = result.files;
214
+ totalSize = result.totalSize;
215
+ if (files.length === 0) {
216
+ throw new Error('No supported files found in folder.');
217
+ }
218
+ entryFile = input.entry_file || autoDetectEntryFile(files);
219
+ if (entryFile && !files.some(f => f.path === entryFile)) {
220
+ throw new Error(`Entry file "${entryFile}" not found in folder. Available files: ${files.map(f => f.path).join(', ')}`);
221
+ }
222
+ }
223
+ else {
224
+ totalSize = validateContentSize(input.content);
225
+ const filename = detectContentFilename(input.content, input.content_filename);
226
+ files = createSingleFileReport(input.content, filename);
227
+ entryFile = input.entry_file || filename;
228
+ }
229
+ const agentId = input.agent_id || config.agentId;
230
+ if (!agentId) {
231
+ throw new Error('agent_id is required - provide it as a parameter or set AGENT_ID env var');
232
+ }
233
+ const result = await client.deployReport({
234
+ name: input.name,
235
+ slug: input.slug,
236
+ description: input.description,
237
+ agent_id: agentId,
238
+ agent_name: input.agent_name || config.agentName,
239
+ entry_file: entryFile,
240
+ tags: input.tags,
241
+ files,
242
+ });
243
+ return {
244
+ success: true,
245
+ report: result.report,
246
+ url: result.url,
247
+ files_uploaded: files.length,
248
+ total_size_bytes: totalSize,
249
+ total_size_formatted: formatSize(totalSize),
250
+ message: `Context "${result.report.name}" deployed successfully. View at: ${result.url}`,
251
+ };
252
+ };
253
+ }
254
+ export function createUpdateTool(client) {
255
+ return async (input) => {
256
+ let files;
257
+ let totalSize = 0;
258
+ if (input.folder_path) {
259
+ const resolvedPath = await verifyFolderExists(input.folder_path);
260
+ const result = await readFolderFiles(resolvedPath);
261
+ files = result.files;
262
+ totalSize = result.totalSize;
263
+ }
264
+ else if (input.content) {
265
+ totalSize = validateContentSize(input.content);
266
+ const filename = detectContentFilename(input.content, input.content_filename);
267
+ files = createSingleFileReport(input.content, filename);
268
+ }
269
+ const result = await client.updateReport(input.slug, {
270
+ name: input.name,
271
+ description: input.description,
272
+ tags: input.tags,
273
+ files,
274
+ });
275
+ return {
276
+ success: true,
277
+ report: result.report,
278
+ url: result.url,
279
+ files_uploaded: files?.length ?? 0,
280
+ total_size_bytes: totalSize,
281
+ total_size_formatted: formatSize(totalSize),
282
+ message: `Context "${result.report.name}" updated successfully (version ${result.report.version}). View at: ${result.url}`,
283
+ };
284
+ };
285
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,182 @@
1
+ import { deployReportSchema, updateReportSchema, shouldIgnoreFile, autoDetectEntryFile } from './deploy.js';
2
+ describe('deployReportSchema', () => {
3
+ it('requires name field', () => {
4
+ const result = deployReportSchema.safeParse({});
5
+ expect(result.success).toBe(false);
6
+ });
7
+ it('accepts valid input with content', () => {
8
+ const result = deployReportSchema.safeParse({
9
+ name: 'Test Report',
10
+ content: '<html><body>Hello</body></html>',
11
+ });
12
+ expect(result.success).toBe(true);
13
+ });
14
+ it('accepts valid input with folder_path', () => {
15
+ const result = deployReportSchema.safeParse({
16
+ name: 'Test Report',
17
+ folder_path: '/path/to/folder',
18
+ });
19
+ expect(result.success).toBe(true);
20
+ });
21
+ it('accepts optional fields', () => {
22
+ const result = deployReportSchema.safeParse({
23
+ name: 'Test Report',
24
+ content: '<html></html>',
25
+ slug: 'test-report',
26
+ description: 'A test report',
27
+ tags: ['test', 'demo'],
28
+ agent_id: 'agent-123',
29
+ agent_name: 'Test Agent',
30
+ });
31
+ expect(result.success).toBe(true);
32
+ if (result.success) {
33
+ expect(result.data.slug).toBe('test-report');
34
+ expect(result.data.tags).toEqual(['test', 'demo']);
35
+ }
36
+ });
37
+ it('accepts content_filename parameter', () => {
38
+ const result = deployReportSchema.safeParse({
39
+ name: 'My Notes',
40
+ content: '# Hello World',
41
+ content_filename: 'README.md',
42
+ });
43
+ expect(result.success).toBe(true);
44
+ if (result.success) {
45
+ expect(result.data.content_filename).toBe('README.md');
46
+ }
47
+ });
48
+ it('accepts non-HTML content', () => {
49
+ const result = deployReportSchema.safeParse({
50
+ name: 'Python Script',
51
+ content: 'print("hello")',
52
+ content_filename: 'main.py',
53
+ });
54
+ expect(result.success).toBe(true);
55
+ });
56
+ });
57
+ describe('updateReportSchema', () => {
58
+ it('requires slug field', () => {
59
+ const result = updateReportSchema.safeParse({});
60
+ expect(result.success).toBe(false);
61
+ });
62
+ it('accepts valid update with content', () => {
63
+ const result = updateReportSchema.safeParse({
64
+ slug: 'test-report',
65
+ content: '<html><body>Updated</body></html>',
66
+ });
67
+ expect(result.success).toBe(true);
68
+ });
69
+ it('accepts partial updates', () => {
70
+ const result = updateReportSchema.safeParse({
71
+ slug: 'test-report',
72
+ name: 'Updated Name',
73
+ });
74
+ expect(result.success).toBe(true);
75
+ });
76
+ it('accepts content_filename parameter', () => {
77
+ const result = updateReportSchema.safeParse({
78
+ slug: 'test-report',
79
+ content: '# Updated notes',
80
+ content_filename: 'notes.md',
81
+ });
82
+ expect(result.success).toBe(true);
83
+ if (result.success) {
84
+ expect(result.data.content_filename).toBe('notes.md');
85
+ }
86
+ });
87
+ });
88
+ describe('shouldIgnoreFile', () => {
89
+ it('ignores .DS_Store', () => {
90
+ expect(shouldIgnoreFile('.DS_Store')).toBe(true);
91
+ });
92
+ it('ignores hidden files starting with dot', () => {
93
+ expect(shouldIgnoreFile('.gitignore')).toBe(true);
94
+ expect(shouldIgnoreFile('.env')).toBe(true);
95
+ expect(shouldIgnoreFile('.hidden')).toBe(true);
96
+ });
97
+ it('ignores backup files ending with ~', () => {
98
+ expect(shouldIgnoreFile('file.html~')).toBe(true);
99
+ expect(shouldIgnoreFile('backup~')).toBe(true);
100
+ });
101
+ it('allows text and documentation files', () => {
102
+ expect(shouldIgnoreFile('readme.md')).toBe(false);
103
+ expect(shouldIgnoreFile('data.txt')).toBe(false);
104
+ expect(shouldIgnoreFile('notes.rst')).toBe(false);
105
+ expect(shouldIgnoreFile('output.log')).toBe(false);
106
+ });
107
+ it('allows code files', () => {
108
+ expect(shouldIgnoreFile('script.py')).toBe(false);
109
+ expect(shouldIgnoreFile('app.ts')).toBe(false);
110
+ expect(shouldIgnoreFile('component.tsx')).toBe(false);
111
+ expect(shouldIgnoreFile('component.jsx')).toBe(false);
112
+ expect(shouldIgnoreFile('main.go')).toBe(false);
113
+ expect(shouldIgnoreFile('lib.rs')).toBe(false);
114
+ expect(shouldIgnoreFile('App.java')).toBe(false);
115
+ expect(shouldIgnoreFile('main.c')).toBe(false);
116
+ expect(shouldIgnoreFile('main.cpp')).toBe(false);
117
+ expect(shouldIgnoreFile('header.h')).toBe(false);
118
+ expect(shouldIgnoreFile('script.rb')).toBe(false);
119
+ expect(shouldIgnoreFile('analysis.r')).toBe(false);
120
+ expect(shouldIgnoreFile('setup.sh')).toBe(false);
121
+ expect(shouldIgnoreFile('deploy.bash')).toBe(false);
122
+ expect(shouldIgnoreFile('utils.mjs')).toBe(false);
123
+ expect(shouldIgnoreFile('config.cjs')).toBe(false);
124
+ });
125
+ it('allows config and data files', () => {
126
+ expect(shouldIgnoreFile('config.yaml')).toBe(false);
127
+ expect(shouldIgnoreFile('config.yml')).toBe(false);
128
+ expect(shouldIgnoreFile('settings.toml')).toBe(false);
129
+ expect(shouldIgnoreFile('data.csv')).toBe(false);
130
+ expect(shouldIgnoreFile('data.tsv')).toBe(false);
131
+ expect(shouldIgnoreFile('feed.xml')).toBe(false);
132
+ expect(shouldIgnoreFile('query.sql')).toBe(false);
133
+ expect(shouldIgnoreFile('settings.ini')).toBe(false);
134
+ expect(shouldIgnoreFile('app.cfg')).toBe(false);
135
+ expect(shouldIgnoreFile('nginx.conf')).toBe(false);
136
+ });
137
+ it('allows notebook files', () => {
138
+ expect(shouldIgnoreFile('analysis.ipynb')).toBe(false);
139
+ });
140
+ it('allows web files', () => {
141
+ expect(shouldIgnoreFile('index.html')).toBe(false);
142
+ expect(shouldIgnoreFile('styles.css')).toBe(false);
143
+ expect(shouldIgnoreFile('app.js')).toBe(false);
144
+ expect(shouldIgnoreFile('logo.png')).toBe(false);
145
+ expect(shouldIgnoreFile('data.json')).toBe(false);
146
+ });
147
+ it('ignores system files', () => {
148
+ expect(shouldIgnoreFile('Thumbs.db')).toBe(true);
149
+ expect(shouldIgnoreFile('desktop.ini')).toBe(true);
150
+ });
151
+ it('ignores unsupported file types', () => {
152
+ expect(shouldIgnoreFile('archive.zip')).toBe(true);
153
+ expect(shouldIgnoreFile('binary.exe')).toBe(true);
154
+ expect(shouldIgnoreFile('movie.mp4')).toBe(true);
155
+ });
156
+ });
157
+ describe('autoDetectEntryFile', () => {
158
+ it('returns index.html if present', () => {
159
+ const files = [{ path: 'README.md' }, { path: 'index.html' }, { path: 'style.css' }];
160
+ expect(autoDetectEntryFile(files)).toBe('index.html');
161
+ });
162
+ it('returns single HTML file if only one exists', () => {
163
+ const files = [{ path: 'report.html' }, { path: 'data.csv' }];
164
+ expect(autoDetectEntryFile(files)).toBe('report.html');
165
+ });
166
+ it('returns README.md if no HTML files', () => {
167
+ const files = [{ path: 'README.md' }, { path: 'main.py' }, { path: 'data.csv' }];
168
+ expect(autoDetectEntryFile(files)).toBe('README.md');
169
+ });
170
+ it('returns null if no suitable entry file', () => {
171
+ const files = [{ path: 'main.py' }, { path: 'data.csv' }];
172
+ expect(autoDetectEntryFile(files)).toBeNull();
173
+ });
174
+ it('returns null if multiple HTML files and no index.html', () => {
175
+ const files = [{ path: 'page1.html' }, { path: 'page2.html' }];
176
+ expect(autoDetectEntryFile(files)).toBeNull();
177
+ });
178
+ it('prefers index.html over README.md', () => {
179
+ const files = [{ path: 'README.md' }, { path: 'index.html' }];
180
+ expect(autoDetectEntryFile(files)).toBe('index.html');
181
+ });
182
+ });
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import type { ApiClient } from '../api-client.js';
3
+ export declare const pullContextSchema: z.ZodObject<{
4
+ slug: z.ZodString;
5
+ output_dir: z.ZodString;
6
+ files: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
7
+ include_binary: z.ZodOptional<z.ZodBoolean>;
8
+ }, "strip", z.ZodTypeAny, {
9
+ slug: string;
10
+ output_dir: string;
11
+ files?: string[] | undefined;
12
+ include_binary?: boolean | undefined;
13
+ }, {
14
+ slug: string;
15
+ output_dir: string;
16
+ files?: string[] | undefined;
17
+ include_binary?: boolean | undefined;
18
+ }>;
19
+ export declare function extractSlug(slugOrUrl: string): string;
20
+ export declare function isTextContentType(contentType: string): boolean;
21
+ export declare function createPullTool(client: ApiClient): (input: z.infer<typeof pullContextSchema>) => Promise<{
22
+ context: {
23
+ name: string;
24
+ slug: string;
25
+ description: string | null;
26
+ version: number;
27
+ };
28
+ files_pulled: number;
29
+ output_dir: string;
30
+ files: {
31
+ path: string;
32
+ size: number;
33
+ }[];
34
+ }>;
@@ -0,0 +1,77 @@
1
+ import { z } from 'zod';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ export const pullContextSchema = z.object({
5
+ slug: z.string().describe('Slug or full URL of the context to pull (e.g., "my-context" or "https://reporter.44pixels.workers.dev/r/my-context/")'),
6
+ output_dir: z.string().describe('Local directory to write files to. Will be created if it does not exist.'),
7
+ files: z.array(z.string()).optional().describe('Specific files to pull. If omitted, all text files are pulled.'),
8
+ include_binary: z.boolean().optional().describe('Include binary files (images, fonts, etc). Default: false.'),
9
+ });
10
+ export function extractSlug(slugOrUrl) {
11
+ const match = slugOrUrl.match(/\/r\/([^/]+)/);
12
+ if (match)
13
+ return match[1];
14
+ return slugOrUrl;
15
+ }
16
+ const BINARY_CONTENT_TYPES = new Set([
17
+ 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/x-icon',
18
+ 'font/woff', 'font/woff2', 'font/ttf', 'application/vnd.ms-fontobject',
19
+ 'application/pdf', 'application/octet-stream',
20
+ ]);
21
+ export function isTextContentType(contentType) {
22
+ return !BINARY_CONTENT_TYPES.has(contentType);
23
+ }
24
+ export function createPullTool(client) {
25
+ return async (input) => {
26
+ const slug = extractSlug(input.slug);
27
+ const outputDir = path.resolve(input.output_dir);
28
+ const { report, files: fileList } = await client.getReport(slug);
29
+ let filePaths = fileList.map(f => f.path);
30
+ if (input.files && input.files.length > 0) {
31
+ const available = new Set(filePaths);
32
+ const missing = input.files.filter(f => !available.has(f));
33
+ if (missing.length > 0) {
34
+ throw new Error(`Files not found in context: ${missing.join(', ')}`);
35
+ }
36
+ filePaths = input.files;
37
+ }
38
+ const { files: pulledFiles } = await client.pullContextFiles(slug, filePaths);
39
+ const filesToWrite = input.include_binary
40
+ ? pulledFiles
41
+ : pulledFiles.filter(f => isTextContentType(f.content_type));
42
+ await fs.mkdir(outputDir, { recursive: true });
43
+ const writtenFiles = [];
44
+ const resolvedOutputDir = path.resolve(outputDir);
45
+ for (const file of filesToWrite) {
46
+ const filePath = path.join(outputDir, file.path);
47
+ const resolvedFilePath = path.resolve(filePath);
48
+ if (!resolvedFilePath.startsWith(resolvedOutputDir + path.sep) && resolvedFilePath !== resolvedOutputDir) {
49
+ throw new Error(`Unsafe file path rejected: ${file.path}`);
50
+ }
51
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
52
+ if (file.content.startsWith('data:')) {
53
+ const commaIndex = file.content.indexOf(',');
54
+ if (commaIndex === -1) {
55
+ throw new Error(`Invalid data URI format for file: ${file.path}`);
56
+ }
57
+ const base64Data = file.content.slice(commaIndex + 1);
58
+ await fs.writeFile(filePath, Buffer.from(base64Data, 'base64'));
59
+ }
60
+ else {
61
+ await fs.writeFile(filePath, file.content, 'utf-8');
62
+ }
63
+ writtenFiles.push({ path: file.path, size: file.size });
64
+ }
65
+ return {
66
+ context: {
67
+ name: report.name,
68
+ slug: report.slug,
69
+ description: report.description,
70
+ version: report.version,
71
+ },
72
+ files_pulled: writtenFiles.length,
73
+ output_dir: outputDir,
74
+ files: writtenFiles,
75
+ };
76
+ };
77
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import * as path from 'path';
2
+ import { pullContextSchema, extractSlug, isTextContentType } from './pull.js';
3
+ describe('pullContextSchema', () => {
4
+ it('requires slug and output_dir', () => {
5
+ const result = pullContextSchema.safeParse({});
6
+ expect(result.success).toBe(false);
7
+ });
8
+ it('accepts valid input with slug and output_dir', () => {
9
+ const result = pullContextSchema.safeParse({
10
+ slug: 'my-context',
11
+ output_dir: '/tmp/output',
12
+ });
13
+ expect(result.success).toBe(true);
14
+ });
15
+ it('accepts optional files array', () => {
16
+ const result = pullContextSchema.safeParse({
17
+ slug: 'my-context',
18
+ output_dir: '/tmp/output',
19
+ files: ['README.md', 'src/main.py'],
20
+ });
21
+ expect(result.success).toBe(true);
22
+ if (result.success) {
23
+ expect(result.data.files).toEqual(['README.md', 'src/main.py']);
24
+ }
25
+ });
26
+ it('accepts optional include_binary flag', () => {
27
+ const result = pullContextSchema.safeParse({
28
+ slug: 'my-context',
29
+ output_dir: '/tmp/output',
30
+ include_binary: true,
31
+ });
32
+ expect(result.success).toBe(true);
33
+ if (result.success) {
34
+ expect(result.data.include_binary).toBe(true);
35
+ }
36
+ });
37
+ it('accepts full URL as slug', () => {
38
+ const result = pullContextSchema.safeParse({
39
+ slug: 'https://reporter.44pixels.workers.dev/r/my-context/',
40
+ output_dir: '/tmp/output',
41
+ });
42
+ expect(result.success).toBe(true);
43
+ });
44
+ });
45
+ describe('extractSlug', () => {
46
+ it('extracts slug from full URL', () => {
47
+ expect(extractSlug('https://reporter.44pixels.workers.dev/r/my-context/')).toBe('my-context');
48
+ });
49
+ it('extracts slug from URL without trailing slash', () => {
50
+ expect(extractSlug('https://reporter.44pixels.workers.dev/r/my-context')).toBe('my-context');
51
+ });
52
+ it('extracts slug from URL with subpath', () => {
53
+ expect(extractSlug('https://reporter.44pixels.workers.dev/r/my-context/index.html')).toBe('my-context');
54
+ });
55
+ it('returns bare slug as-is', () => {
56
+ expect(extractSlug('my-context')).toBe('my-context');
57
+ });
58
+ it('returns slug with hyphens and numbers', () => {
59
+ expect(extractSlug('report-2024-01')).toBe('report-2024-01');
60
+ });
61
+ });
62
+ describe('path traversal safety', () => {
63
+ it('rejects paths with directory traversal', () => {
64
+ const outputDir = '/tmp/test-output';
65
+ const maliciousPath = '../../etc/passwd';
66
+ const resolved = path.resolve(path.join(outputDir, maliciousPath));
67
+ const resolvedOutput = path.resolve(outputDir);
68
+ expect(resolved.startsWith(resolvedOutput + path.sep)).toBe(false);
69
+ });
70
+ it('allows safe nested paths', () => {
71
+ const outputDir = '/tmp/test-output';
72
+ const safePath = 'src/lib/utils.ts';
73
+ const resolved = path.resolve(path.join(outputDir, safePath));
74
+ const resolvedOutput = path.resolve(outputDir);
75
+ expect(resolved.startsWith(resolvedOutput + path.sep)).toBe(true);
76
+ });
77
+ it('rejects paths with encoded traversal', () => {
78
+ const outputDir = '/tmp/test-output';
79
+ // Even sneaky traversal like 'foo/../../etc/passwd' is caught
80
+ const maliciousPath = 'foo/../../etc/passwd';
81
+ const resolved = path.resolve(path.join(outputDir, maliciousPath));
82
+ const resolvedOutput = path.resolve(outputDir);
83
+ expect(resolved.startsWith(resolvedOutput + path.sep)).toBe(false);
84
+ });
85
+ });
86
+ describe('isTextContentType', () => {
87
+ it('returns true for text content types', () => {
88
+ expect(isTextContentType('text/html')).toBe(true);
89
+ expect(isTextContentType('text/markdown')).toBe(true);
90
+ expect(isTextContentType('text/plain')).toBe(true);
91
+ expect(isTextContentType('text/css')).toBe(true);
92
+ expect(isTextContentType('text/yaml')).toBe(true);
93
+ expect(isTextContentType('text/x-python')).toBe(true);
94
+ expect(isTextContentType('application/javascript')).toBe(true);
95
+ expect(isTextContentType('application/json')).toBe(true);
96
+ expect(isTextContentType('application/xml')).toBe(true);
97
+ expect(isTextContentType('text/typescript')).toBe(true);
98
+ });
99
+ it('returns false for binary content types', () => {
100
+ expect(isTextContentType('image/png')).toBe(false);
101
+ expect(isTextContentType('image/jpeg')).toBe(false);
102
+ expect(isTextContentType('image/gif')).toBe(false);
103
+ expect(isTextContentType('font/woff')).toBe(false);
104
+ expect(isTextContentType('font/woff2')).toBe(false);
105
+ expect(isTextContentType('application/pdf')).toBe(false);
106
+ expect(isTextContentType('application/octet-stream')).toBe(false);
107
+ });
108
+ });
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ import type { ApiClient } from '../api-client.js';
3
+ export declare const listReportsSchema: z.ZodObject<{
4
+ agent_id: z.ZodOptional<z.ZodString>;
5
+ search: z.ZodOptional<z.ZodString>;
6
+ limit: z.ZodOptional<z.ZodNumber>;
7
+ offset: z.ZodOptional<z.ZodNumber>;
8
+ }, "strip", z.ZodTypeAny, {
9
+ agent_id?: string | undefined;
10
+ search?: string | undefined;
11
+ limit?: number | undefined;
12
+ offset?: number | undefined;
13
+ }, {
14
+ agent_id?: string | undefined;
15
+ search?: string | undefined;
16
+ limit?: number | undefined;
17
+ offset?: number | undefined;
18
+ }>;
19
+ export declare const getReportSchema: z.ZodObject<{
20
+ slug: z.ZodString;
21
+ }, "strip", z.ZodTypeAny, {
22
+ slug: string;
23
+ }, {
24
+ slug: string;
25
+ }>;
26
+ export declare const deleteReportSchema: z.ZodObject<{
27
+ slug: z.ZodString;
28
+ }, "strip", z.ZodTypeAny, {
29
+ slug: string;
30
+ }, {
31
+ slug: string;
32
+ }>;
33
+ export declare function createListTool(client: ApiClient): (input: z.infer<typeof listReportsSchema>) => Promise<{
34
+ count: number;
35
+ reports: import("../api-client.js").Report[];
36
+ }>;
37
+ export declare function createGetTool(client: ApiClient): (input: z.infer<typeof getReportSchema>) => Promise<{
38
+ report: import("../api-client.js").Report;
39
+ files: import("../api-client.js").FileInfo[];
40
+ }>;
41
+ export declare function createDeleteTool(client: ApiClient): (input: z.infer<typeof deleteReportSchema>) => Promise<{
42
+ success: boolean;
43
+ message: string;
44
+ }>;
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ export const listReportsSchema = z.object({
3
+ agent_id: z.string().optional().describe('Filter by agent ID'),
4
+ search: z.string().optional().describe('Search in name, description, and tags'),
5
+ limit: z.number().optional().describe('Maximum number of results (default: 50)'),
6
+ offset: z.number().optional().describe('Offset for pagination'),
7
+ });
8
+ export const getReportSchema = z.object({
9
+ slug: z.string().describe('Slug of the report to retrieve'),
10
+ });
11
+ export const deleteReportSchema = z.object({
12
+ slug: z.string().describe('Slug of the report to delete'),
13
+ });
14
+ export function createListTool(client) {
15
+ return async (input) => {
16
+ const { reports } = await client.listReports(input);
17
+ return { count: reports.length, reports };
18
+ };
19
+ }
20
+ export function createGetTool(client) {
21
+ return async (input) => client.getReport(input.slug);
22
+ }
23
+ export function createDeleteTool(client) {
24
+ return async (input) => {
25
+ await client.deleteReport(input.slug);
26
+ return { success: true, message: `Report "${input.slug}" deleted successfully` };
27
+ };
28
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { listReportsSchema, getReportSchema, deleteReportSchema } from './reports.js';
2
+ describe('listReportsSchema', () => {
3
+ it('accepts empty input', () => {
4
+ const result = listReportsSchema.safeParse({});
5
+ expect(result.success).toBe(true);
6
+ });
7
+ it('accepts all optional filters', () => {
8
+ const result = listReportsSchema.safeParse({
9
+ agent_id: 'agent-123',
10
+ search: 'test query',
11
+ limit: 10,
12
+ offset: 20,
13
+ });
14
+ expect(result.success).toBe(true);
15
+ if (result.success) {
16
+ expect(result.data.limit).toBe(10);
17
+ expect(result.data.offset).toBe(20);
18
+ }
19
+ });
20
+ });
21
+ describe('getReportSchema', () => {
22
+ it('requires slug', () => {
23
+ const result = getReportSchema.safeParse({});
24
+ expect(result.success).toBe(false);
25
+ });
26
+ it('accepts valid slug', () => {
27
+ const result = getReportSchema.safeParse({ slug: 'my-report' });
28
+ expect(result.success).toBe(true);
29
+ });
30
+ });
31
+ describe('deleteReportSchema', () => {
32
+ it('requires slug', () => {
33
+ const result = deleteReportSchema.safeParse({});
34
+ expect(result.success).toBe(false);
35
+ });
36
+ it('accepts valid slug', () => {
37
+ const result = deleteReportSchema.safeParse({ slug: 'my-report' });
38
+ expect(result.success).toBe(true);
39
+ });
40
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "44reports-mcp",
3
+ "version": "1.0.7",
4
+ "type": "module",
5
+ "description": "MCP server for deploying and pulling context files (code, docs, data) to/from 44reports",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "44reports-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/44-pixels/agent-reporter.git"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "prepublishOnly": "npm run build",
20
+ "start": "node dist/index.js",
21
+ "dev": "tsx watch src/index.ts",
22
+ "test": "NODE_OPTIONS='--experimental-vm-modules' npx jest",
23
+ "test:watch": "NODE_OPTIONS='--experimental-vm-modules' npx jest --watch"
24
+ },
25
+ "keywords": [
26
+ "mcp",
27
+ "reporter",
28
+ "context-repository",
29
+ "claude",
30
+ "ai-agents",
31
+ "cross-session-memory"
32
+ ],
33
+ "license": "MIT",
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.0.0",
36
+ "zod": "^3.23.8"
37
+ },
38
+ "devDependencies": {
39
+ "@types/jest": "^30.0.0",
40
+ "@types/node": "^20.11.0",
41
+ "jest": "^30.2.0",
42
+ "ts-jest": "^29.4.6",
43
+ "tsx": "^4.7.0",
44
+ "typescript": "^5.3.3"
45
+ }
46
+ }