aimetadatacleaner-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +57 -0
  2. package/index.js +328 -0
  3. package/package.json +32 -0
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # AI Metadata Cleaner MCP server
2
+
3
+ Remove hidden metadata from your files by asking your AI assistant:
4
+
5
+ > "Remove the location and camera details from the videos in ~/Downloads/trip"
6
+
7
+ It removes GPS locations and tracks, phone and camera models, serial numbers, dates, author and
8
+ software names, and AI-generation labels from:
9
+
10
+ - **Video:** MP4, MOV, M4V, 3GP, MKV, WebM (video and sound are not re-encoded)
11
+ - **PDF**
12
+ - **Camera RAW:** CR2, CR3, NEF, ARW, RAF, RW2, DNG
13
+ - **Photos:** JPEG, PNG, WebP
14
+
15
+ Each file is sent to the [AI Metadata Cleaner](https://aimetadatacleaner.com) server, cleaned,
16
+ checked, and saved next to the original as `name-clean.ext`. Your originals are never changed.
17
+ Files are deleted from the server right after cleaning, and the clean copy right after it is
18
+ downloaded.
19
+
20
+ ## You need
21
+
22
+ - Node.js 18.17 or newer
23
+ - An API key. Keys come with the Business plan: <https://aimetadatacleaner.com/api-keys>
24
+
25
+ ## Set up
26
+
27
+ **Claude Desktop** — Settings → Developer → Edit Config, then add:
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "aimetadatacleaner": {
33
+ "command": "npx",
34
+ "args": ["-y", "aimetadatacleaner-mcp"],
35
+ "env": { "AMC_API_KEY": "amc_live_..." }
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ **Claude Code**
42
+
43
+ ```sh
44
+ claude mcp add aimetadatacleaner -e AMC_API_KEY=amc_live_... -- npx -y aimetadatacleaner-mcp
45
+ ```
46
+
47
+ **Cursor** — add the same block as Claude Desktop to `~/.cursor/mcp.json`.
48
+
49
+ Full guide for every app: <https://aimetadatacleaner.com/mcp>
50
+
51
+ ## Tools
52
+
53
+ - `clean_files` — clean files or folders (a folder means the supported files directly inside it).
54
+ Options: `output_dir`, `image_mode` (`reencode` or `strip`), `keep_pdf_signature`.
55
+ - `check_usage` — jobs and data used this month.
56
+
57
+ Each file is one job from your monthly allowance.
package/index.js ADDED
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AI Metadata Cleaner — MCP server for AI assistants (Claude Desktop, Claude Code, Cursor, ...).
4
+ *
5
+ * A thin client: it reads the user's files from disk, sends each one to the AI Metadata Cleaner
6
+ * API, and saves the clean copy next to the original. All cleaning and all counting happen on
7
+ * the server, so this package holds no cleaning logic and nothing here can skip the plan limits.
8
+ *
9
+ * Needs AMC_API_KEY (a Business key from https://aimetadatacleaner.com/api-keys).
10
+ */
11
+ import { createReadStream, createWriteStream } from 'node:fs';
12
+ import { stat, readdir, rename, unlink } from 'node:fs/promises';
13
+ import { homedir } from 'node:os';
14
+ import path from 'node:path';
15
+ import { Readable } from 'node:stream';
16
+ import { pipeline } from 'node:stream/promises';
17
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
+ import { z } from 'zod';
20
+
21
+ const VERSION = '0.1.0';
22
+ const API = (process.env.AMC_API_URL || 'https://api.aimetadatacleaner.com').replace(/\/+$/, '');
23
+ const KEY = (process.env.AMC_API_KEY || '').trim();
24
+ const KEYS_URL = 'https://aimetadatacleaner.com/api-keys';
25
+ const MAX_FILES = 50;
26
+
27
+ // What the server cleans. HEIC is cleaned in the browser on the website, not by the API.
28
+ const SUPPORTED = new Set([
29
+ '.mp4', '.mov', '.m4v', '.3gp', '.mkv', '.webm',
30
+ '.pdf',
31
+ '.cr2', '.cr3', '.nef', '.arw', '.raf', '.rw2', '.dng',
32
+ '.jpg', '.jpeg', '.png', '.webp'
33
+ ]);
34
+
35
+ const LABELS = {
36
+ clean: 'Clean',
37
+ unverified: 'Cleaned, but not everything could be confirmed removed',
38
+ failed: 'Not cleaned',
39
+ not_sent: 'Not sent'
40
+ };
41
+
42
+ class ApiError extends Error {
43
+ constructor(status, code, message, retryAfter) {
44
+ super(message);
45
+ this.status = status;
46
+ this.code = code;
47
+ this.retryAfter = retryAfter;
48
+ }
49
+ }
50
+
51
+ function expandHome(p) {
52
+ return p === '~' || p.startsWith('~/') ? path.join(homedir(), p.slice(1)) : p;
53
+ }
54
+
55
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
56
+
57
+ async function apiError(res) {
58
+ let code = `http_${res.status}`;
59
+ let message = `The server answered ${res.status}.`;
60
+ try {
61
+ const body = await res.json();
62
+ if (body?.error) ({ code, message } = body.error);
63
+ } catch {}
64
+ return new ApiError(res.status, code, message, Number(res.headers.get('retry-after')) || 0);
65
+ }
66
+
67
+ function authHeaders() {
68
+ return { Authorization: `Bearer ${KEY}`, 'User-Agent': `aimetadatacleaner-mcp/${VERSION}` };
69
+ }
70
+
71
+ /** Busy (503) and rate-limited (429) are worth one wait-and-retry; everything else is final. */
72
+ async function withRetry(fn) {
73
+ try {
74
+ return await fn();
75
+ } catch (e) {
76
+ if (e instanceof ApiError && (e.status === 429 || e.status === 503)) {
77
+ await sleep(Math.min(Math.max(e.retryAfter, 5), 60) * 1000);
78
+ return fn();
79
+ }
80
+ throw e;
81
+ }
82
+ }
83
+
84
+ async function uploadAndClean(file, size, options) {
85
+ const query = new URLSearchParams();
86
+ if (options.mode) query.set('mode', options.mode);
87
+ if (options.keep_pdf_signature) query.set('keep_signature', 'true');
88
+ const qs = query.toString();
89
+ const res = await fetch(`${API}/v1/clean${qs ? `?${qs}` : ''}`, {
90
+ method: 'POST',
91
+ headers: {
92
+ ...authHeaders(),
93
+ 'Content-Type': 'application/octet-stream',
94
+ 'Content-Length': String(size),
95
+ // Only the extension matters to the server (some formats are detected by it). Header
96
+ // values must be plain ASCII, and the saved name is chosen here from the real one anyway.
97
+ 'X-Filename': `upload${path.extname(file).toLowerCase().replace(/[^.a-z0-9]/g, '')}`
98
+ },
99
+ body: Readable.toWeb(createReadStream(file)),
100
+ duplex: 'half'
101
+ });
102
+ if (!res.ok) throw await apiError(res);
103
+ return res.json();
104
+ }
105
+
106
+ /** A name next to the original that doesn't exist yet: photo-clean.jpg, photo-clean (2).jpg ... */
107
+ async function freeName(dir, stem, ext) {
108
+ for (let n = 1; ; n++) {
109
+ const candidate = path.join(dir, `${stem}-clean${n > 1 ? ` (${n})` : ''}${ext}`);
110
+ try {
111
+ await stat(candidate);
112
+ } catch {
113
+ return candidate;
114
+ }
115
+ }
116
+ }
117
+
118
+ function extFromDisposition(header) {
119
+ const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(header || '');
120
+ return m ? path.extname(decodeURIComponent(m[1])).toLowerCase() : '';
121
+ }
122
+
123
+ async function download(url, file, outputDir) {
124
+ const res = await fetch(url, { headers: { 'User-Agent': `aimetadatacleaner-mcp/${VERSION}` } });
125
+ if (!res.ok || !res.body) throw await apiError(res);
126
+ // The server may change the extension (e.g. a converted image), so take it from the reply.
127
+ const ext = extFromDisposition(res.headers.get('content-disposition')) || path.extname(file);
128
+ const dest = await freeName(outputDir || path.dirname(file), path.basename(file, path.extname(file)), ext);
129
+ const partial = `${dest}.part`;
130
+ try {
131
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(partial, { flags: 'wx' }));
132
+ await rename(partial, dest);
133
+ } catch (e) {
134
+ await unlink(partial).catch(() => {});
135
+ throw e;
136
+ }
137
+ return dest;
138
+ }
139
+
140
+ /** Files named directly, plus the supported files directly inside any folder named. */
141
+ async function collect(paths) {
142
+ const files = [];
143
+ const skipped = [];
144
+ for (const raw of paths) {
145
+ const p = path.resolve(expandHome(raw));
146
+ let s;
147
+ try {
148
+ s = await stat(p);
149
+ } catch {
150
+ skipped.push({ path: p, reason: 'not found' });
151
+ continue;
152
+ }
153
+ if (s.isDirectory()) {
154
+ for (const entry of (await readdir(p, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
155
+ if (!entry.isFile() || entry.name.startsWith('.')) continue;
156
+ const ext = path.extname(entry.name).toLowerCase();
157
+ // Earlier clean copies sit in the same folder; cleaning them again would just use up jobs.
158
+ if (!SUPPORTED.has(ext) || /-clean( \(\d+\))?$/.test(path.basename(entry.name, ext))) continue;
159
+ files.push(path.join(p, entry.name));
160
+ }
161
+ } else if (s.isFile()) {
162
+ files.push(p);
163
+ } else {
164
+ skipped.push({ path: p, reason: 'not a file' });
165
+ }
166
+ }
167
+ return { files: [...new Set(files)], skipped };
168
+ }
169
+
170
+ function describe(r) {
171
+ const lines = [`${path.basename(r.source)}: ${LABELS[r.status] || r.status}`];
172
+ if (r.saved_to) lines.push(` saved to: ${r.saved_to}`);
173
+ if (r.removed?.length) lines.push(` removed: ${r.removed.join(', ')}`);
174
+ if (r.kept?.length) lines.push(` kept on purpose: ${r.kept.join(', ')}`);
175
+ if (r.left && Object.keys(r.left).length) lines.push(` still present: ${Object.keys(r.left).join(', ')}`);
176
+ for (const n of r.notes || []) lines.push(` note: ${n}`);
177
+ for (const p of r.problems || []) lines.push(` problem: ${p}`);
178
+ if (r.error) lines.push(` ${r.error}`);
179
+ return lines.join('\n');
180
+ }
181
+
182
+ function noKey() {
183
+ return {
184
+ isError: true,
185
+ content: [{
186
+ type: 'text',
187
+ text: `No API key is set. Add AMC_API_KEY to this MCP server's settings. Keys come with the Business plan: ${KEYS_URL}`
188
+ }]
189
+ };
190
+ }
191
+
192
+ const server = new McpServer({ name: 'aimetadatacleaner', version: VERSION });
193
+
194
+ server.registerTool(
195
+ 'clean_files',
196
+ {
197
+ title: 'Remove metadata from files',
198
+ description:
199
+ 'Removes hidden metadata (GPS location, camera or phone model, serial numbers, dates, author, ' +
200
+ 'software, AI-generation labels and credentials) from videos (MP4, MOV, M4V, 3GP, MKV, WebM), ' +
201
+ 'PDFs, camera RAW files (CR2, CR3, NEF, ARW, RAF, RW2, DNG) and photos (JPEG, PNG, WebP). ' +
202
+ 'Each file is sent to the AI Metadata Cleaner server, cleaned, and a clean copy is saved next ' +
203
+ 'to the original as "<name>-clean.<ext>". Originals are never changed. Video and audio are not ' +
204
+ 're-encoded. Pass files or folders (a folder means the supported files directly inside it). ' +
205
+ `Each file uses one job from the monthly allowance. At most ${MAX_FILES} files per call.`,
206
+ inputSchema: {
207
+ paths: z.array(z.string()).min(1).describe('Absolute paths to files or folders. ~ is allowed.'),
208
+ output_dir: z.string().optional().describe('Folder to save clean copies in. Default: next to each original.'),
209
+ image_mode: z.enum(['reencode', 'strip']).optional()
210
+ .describe('Photos only. "reencode" (default) rebuilds the image, the most thorough. "strip" removes metadata without touching the pixels.'),
211
+ keep_pdf_signature: z.boolean().optional()
212
+ .describe('PDFs only. Keep a digital signature (it carries the signer and signing date). Default false: signatures are removed.')
213
+ },
214
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
215
+ },
216
+ async ({ paths, output_dir, image_mode, keep_pdf_signature }, extra) => {
217
+ if (!KEY) return noKey();
218
+
219
+ let outputDir;
220
+ if (output_dir) {
221
+ outputDir = path.resolve(expandHome(output_dir));
222
+ const s = await stat(outputDir).catch(() => null);
223
+ if (!s?.isDirectory()) {
224
+ return { isError: true, content: [{ type: 'text', text: `Output folder not found: ${outputDir}` }] };
225
+ }
226
+ }
227
+
228
+ const { files, skipped } = await collect(paths);
229
+ if (!files.length) {
230
+ const why = skipped.map((s) => `${s.path}: ${s.reason}`).join('\n');
231
+ return { isError: true, content: [{ type: 'text', text: `No files to clean.${why ? `\n${why}` : ''}` }] };
232
+ }
233
+ if (files.length > MAX_FILES) {
234
+ return {
235
+ isError: true,
236
+ content: [{ type: 'text', text: `That is ${files.length} files. Send at most ${MAX_FILES} per call.` }]
237
+ };
238
+ }
239
+
240
+ const progressToken = extra?._meta?.progressToken;
241
+ const results = [];
242
+ let stop = null;
243
+ for (const [i, file] of files.entries()) {
244
+ if (progressToken !== undefined) {
245
+ await extra.sendNotification({
246
+ method: 'notifications/progress',
247
+ params: { progressToken, progress: i, total: files.length, message: `Cleaning ${path.basename(file)}` }
248
+ }).catch(() => {});
249
+ }
250
+ if (stop) {
251
+ results.push({ source: file, status: 'not_sent', error: 'Not sent: stopped after the error above.' });
252
+ continue;
253
+ }
254
+ try {
255
+ const { size } = await stat(file);
256
+ const report = await withRetry(() =>
257
+ uploadAndClean(file, size, { mode: image_mode, keep_pdf_signature })
258
+ );
259
+ const saved = await download(report.download_url, file, outputDir);
260
+ results.push({
261
+ source: file,
262
+ saved_to: saved,
263
+ status: report.status,
264
+ file_type: report.file_type,
265
+ removed: report.removed,
266
+ kept: report.kept,
267
+ left: report.left,
268
+ notes: report.notes,
269
+ problems: report.problems,
270
+ usage: report.usage
271
+ });
272
+ } catch (e) {
273
+ const message = e instanceof ApiError ? e.message : `Could not reach the server: ${e.message}`;
274
+ results.push({ source: file, status: 'failed', error: message });
275
+ // Out of allowance, bad key or no API on this plan: every remaining file would fail the same way.
276
+ if (e instanceof ApiError && (e.status === 401 || e.status === 402 || e.status === 403)) stop = e;
277
+ }
278
+ }
279
+
280
+ const cleaned = results.filter((r) => r.saved_to).length;
281
+ const last = [...results].reverse().find((r) => r.usage)?.usage;
282
+ const summary = [
283
+ `Cleaned ${cleaned} of ${files.length} file${files.length === 1 ? '' : 's'}.`,
284
+ ...(last ? [`Jobs used this month: ${last.jobs_used.toLocaleString('en-US')} of ${last.job_limit.toLocaleString('en-US')}.`] : []),
285
+ '',
286
+ ...results.map(describe),
287
+ ...skipped.map((s) => `${s.path}: skipped (${s.reason})`)
288
+ ].join('\n');
289
+
290
+ return {
291
+ isError: cleaned === 0,
292
+ content: [{ type: 'text', text: summary }],
293
+ structuredContent: { cleaned, total: files.length, results, skipped }
294
+ };
295
+ }
296
+ );
297
+
298
+ server.registerTool(
299
+ 'check_usage',
300
+ {
301
+ title: 'Check monthly allowance',
302
+ description: 'Shows how many cleaning jobs and how much data this month\'s plan has used and allows.',
303
+ inputSchema: {},
304
+ annotations: { readOnlyHint: true, openWorldHint: true }
305
+ },
306
+ async () => {
307
+ if (!KEY) return noKey();
308
+ try {
309
+ const res = await fetch(`${API}/v1/usage`, { headers: authHeaders() });
310
+ if (!res.ok) throw await apiError(res);
311
+ const u = await res.json();
312
+ const gb = (b) => `${(b / 1024 ** 3).toFixed(1)}GB`;
313
+ const text = [
314
+ `Plan: ${u.plan}`,
315
+ `Jobs: ${u.jobs_used.toLocaleString('en-US')} of ${u.job_limit.toLocaleString('en-US')} this month`,
316
+ `Data: ${gb(u.bytes_used)} of ${gb(u.byte_limit)} this month`,
317
+ `Largest file: ${gb(u.max_file_bytes)}`,
318
+ 'Allowances reset on the 1st of each month (UTC).'
319
+ ].join('\n');
320
+ return { content: [{ type: 'text', text }], structuredContent: u };
321
+ } catch (e) {
322
+ const message = e instanceof ApiError ? e.message : `Could not reach the server: ${e.message}`;
323
+ return { isError: true, content: [{ type: 'text', text: message }] };
324
+ }
325
+ }
326
+ );
327
+
328
+ await server.connect(new StdioServerTransport());
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "aimetadatacleaner-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Remove metadata (GPS, device, dates, AI labels) from video, PDF, RAW and photos from Claude, Cursor and other AI assistants. Uses the AI Metadata Cleaner API.",
5
+ "type": "module",
6
+ "bin": {
7
+ "aimetadatacleaner-mcp": "index.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18.17"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "metadata",
19
+ "exif",
20
+ "gps",
21
+ "privacy",
22
+ "video",
23
+ "pdf",
24
+ "raw"
25
+ ],
26
+ "homepage": "https://aimetadatacleaner.com/mcp",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.30.1",
30
+ "zod": "^4.6.5"
31
+ }
32
+ }