@theihtisham/mcp-server-firebase 1.0.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.
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +79 -0
- package/dist/services/firebase.d.ts +14 -0
- package/dist/services/firebase.js +163 -0
- package/dist/tools/auth.d.ts +3 -0
- package/dist/tools/auth.js +346 -0
- package/dist/tools/firestore.d.ts +3 -0
- package/dist/tools/firestore.js +802 -0
- package/dist/tools/functions.d.ts +3 -0
- package/dist/tools/functions.js +168 -0
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.js +30 -0
- package/dist/tools/messaging.d.ts +3 -0
- package/dist/tools/messaging.js +296 -0
- package/dist/tools/realtime-db.d.ts +4 -0
- package/dist/tools/realtime-db.js +271 -0
- package/dist/tools/storage.d.ts +3 -0
- package/dist/tools/storage.js +279 -0
- package/dist/tools/types.d.ts +11 -0
- package/dist/tools/types.js +3 -0
- package/dist/utils/cache.d.ts +16 -0
- package/dist/utils/cache.js +75 -0
- package/dist/utils/errors.d.ts +15 -0
- package/dist/utils/errors.js +94 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.js +37 -0
- package/dist/utils/pagination.d.ts +28 -0
- package/dist/utils/pagination.js +75 -0
- package/dist/utils/validation.d.ts +22 -0
- package/dist/utils/validation.js +172 -0
- package/package.json +53 -0
- package/src/index.ts +94 -0
- package/src/services/firebase.ts +140 -0
- package/src/tools/auth.ts +375 -0
- package/src/tools/firestore.ts +931 -0
- package/src/tools/functions.ts +189 -0
- package/src/tools/index.ts +24 -0
- package/src/tools/messaging.ts +324 -0
- package/src/tools/realtime-db.ts +307 -0
- package/src/tools/storage.ts +314 -0
- package/src/tools/types.ts +10 -0
- package/src/utils/cache.ts +82 -0
- package/src/utils/errors.ts +110 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/pagination.ts +105 -0
- package/src/utils/validation.ts +212 -0
- package/tests/cache.test.ts +139 -0
- package/tests/errors.test.ts +132 -0
- package/tests/firebase-service.test.ts +46 -0
- package/tests/pagination.test.ts +26 -0
- package/tests/tools.test.ts +226 -0
- package/tests/validation.test.ts +216 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { getRealtimeDb } from '../services/firebase.js';
|
|
2
|
+
import {
|
|
3
|
+
handleFirebaseError,
|
|
4
|
+
formatSuccess,
|
|
5
|
+
} from '../utils/index.js';
|
|
6
|
+
import type { ToolDefinition } from './types.js';
|
|
7
|
+
import type { Query } from 'firebase-admin/database';
|
|
8
|
+
|
|
9
|
+
// ============================================================
|
|
10
|
+
// REALTIME DATABASE TOOLS
|
|
11
|
+
// ============================================================
|
|
12
|
+
|
|
13
|
+
export function validateRtdbPath(path: string): string {
|
|
14
|
+
const trimmed = path.trim();
|
|
15
|
+
if (trimmed.length === 0) {
|
|
16
|
+
throw new Error('Realtime Database path cannot be empty.');
|
|
17
|
+
}
|
|
18
|
+
if (!trimmed.startsWith('/')) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Invalid Realtime Database path: "${trimmed}". Path must start with "/".`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
if (trimmed.includes('//')) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Invalid Realtime Database path: "${trimmed}". Path must not contain "//".`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (trimmed.includes('..')) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Invalid Realtime Database path: "${trimmed}". Path must not contain "..".`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const segments = trimmed.split('/').filter(Boolean);
|
|
34
|
+
for (const seg of segments) {
|
|
35
|
+
if (seg.includes('.')) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Invalid segment "${seg}" in path. Segments must not contain ".".`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (seg.includes('$')) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Invalid segment "${seg}" in path. Segments must not contain "$".`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (seg.includes('#')) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Invalid segment "${seg}" in path. Segments must not contain "#".`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (seg.includes('[') || seg.includes(']')) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Invalid segment "${seg}" in path. Segments must not contain "[" or "]".`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return trimmed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const realtimeDbTools: ToolDefinition[] = [
|
|
60
|
+
// ── rtdb_get_data ─────────────────────────────────────
|
|
61
|
+
{
|
|
62
|
+
name: 'rtdb_get_data',
|
|
63
|
+
description:
|
|
64
|
+
'Read data from Firebase Realtime Database at a given path.',
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: 'object' as const,
|
|
67
|
+
properties: {
|
|
68
|
+
path: { type: 'string', description: 'Database path (e.g., "/users/uid123")' },
|
|
69
|
+
},
|
|
70
|
+
required: ['path'],
|
|
71
|
+
},
|
|
72
|
+
handler: async (args: Record<string, unknown>) => {
|
|
73
|
+
try {
|
|
74
|
+
const path = validateRtdbPath(args['path'] as string);
|
|
75
|
+
const db = getRealtimeDb();
|
|
76
|
+
const snapshot = await db.ref(path).once('value');
|
|
77
|
+
|
|
78
|
+
return formatSuccess({
|
|
79
|
+
path,
|
|
80
|
+
exists: snapshot.exists(),
|
|
81
|
+
data: snapshot.val(),
|
|
82
|
+
});
|
|
83
|
+
} catch (err) {
|
|
84
|
+
handleFirebaseError(err, 'rtdb', 'get_data');
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// ── rtdb_set_data ─────────────────────────────────────
|
|
90
|
+
{
|
|
91
|
+
name: 'rtdb_set_data',
|
|
92
|
+
description:
|
|
93
|
+
'Set data at a path in Realtime Database. Overwrites any existing data.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: 'object' as const,
|
|
96
|
+
properties: {
|
|
97
|
+
path: { type: 'string', description: 'Database path' },
|
|
98
|
+
data: { description: 'Data to set (any JSON-compatible value)' },
|
|
99
|
+
},
|
|
100
|
+
required: ['path', 'data'],
|
|
101
|
+
},
|
|
102
|
+
handler: async (args: Record<string, unknown>) => {
|
|
103
|
+
try {
|
|
104
|
+
const path = validateRtdbPath(args['path'] as string);
|
|
105
|
+
const data = args['data'];
|
|
106
|
+
|
|
107
|
+
const db = getRealtimeDb();
|
|
108
|
+
await db.ref(path).set(data);
|
|
109
|
+
|
|
110
|
+
return formatSuccess({
|
|
111
|
+
path,
|
|
112
|
+
message: `Data set successfully at "${path}".`,
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
handleFirebaseError(err, 'rtdb', 'set_data');
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
// ── rtdb_push_data ────────────────────────────────────
|
|
121
|
+
{
|
|
122
|
+
name: 'rtdb_push_data',
|
|
123
|
+
description:
|
|
124
|
+
'Push new data to a list in Realtime Database. Auto-generates a unique key.',
|
|
125
|
+
inputSchema: {
|
|
126
|
+
type: 'object' as const,
|
|
127
|
+
properties: {
|
|
128
|
+
path: { type: 'string', description: 'Parent path for the new item' },
|
|
129
|
+
data: { description: 'Data to push' },
|
|
130
|
+
},
|
|
131
|
+
required: ['path', 'data'],
|
|
132
|
+
},
|
|
133
|
+
handler: async (args: Record<string, unknown>) => {
|
|
134
|
+
try {
|
|
135
|
+
const path = validateRtdbPath(args['path'] as string);
|
|
136
|
+
const data = args['data'];
|
|
137
|
+
|
|
138
|
+
const db = getRealtimeDb();
|
|
139
|
+
const newRef = db.ref(path).push();
|
|
140
|
+
await newRef.set(data);
|
|
141
|
+
|
|
142
|
+
// Get the path string from the reference
|
|
143
|
+
const newPath = newRef.toString().replace(newRef.root.toString(), '');
|
|
144
|
+
|
|
145
|
+
return formatSuccess({
|
|
146
|
+
path: newPath,
|
|
147
|
+
key: newRef.key,
|
|
148
|
+
message: `Data pushed successfully. New key: "${newRef.key}".`,
|
|
149
|
+
});
|
|
150
|
+
} catch (err) {
|
|
151
|
+
handleFirebaseError(err, 'rtdb', 'push_data');
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
// ── rtdb_update_data ──────────────────────────────────
|
|
157
|
+
{
|
|
158
|
+
name: 'rtdb_update_data',
|
|
159
|
+
description:
|
|
160
|
+
'Update specific fields at a path without overwriting the entire location.',
|
|
161
|
+
inputSchema: {
|
|
162
|
+
type: 'object' as const,
|
|
163
|
+
properties: {
|
|
164
|
+
path: { type: 'string', description: 'Database path to update' },
|
|
165
|
+
data: { type: 'object', description: 'Fields to update' },
|
|
166
|
+
},
|
|
167
|
+
required: ['path', 'data'],
|
|
168
|
+
},
|
|
169
|
+
handler: async (args: Record<string, unknown>) => {
|
|
170
|
+
try {
|
|
171
|
+
const path = validateRtdbPath(args['path'] as string);
|
|
172
|
+
const data = args['data'] as Record<string, unknown>;
|
|
173
|
+
|
|
174
|
+
const db = getRealtimeDb();
|
|
175
|
+
await db.ref(path).update(data);
|
|
176
|
+
|
|
177
|
+
return formatSuccess({
|
|
178
|
+
path,
|
|
179
|
+
updatedFields: Object.keys(data),
|
|
180
|
+
message: `Data updated successfully at "${path}".`,
|
|
181
|
+
});
|
|
182
|
+
} catch (err) {
|
|
183
|
+
handleFirebaseError(err, 'rtdb', 'update_data');
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
// ── rtdb_remove_data ──────────────────────────────────
|
|
189
|
+
{
|
|
190
|
+
name: 'rtdb_remove_data',
|
|
191
|
+
description: 'Remove data at a path in Realtime Database.',
|
|
192
|
+
inputSchema: {
|
|
193
|
+
type: 'object' as const,
|
|
194
|
+
properties: {
|
|
195
|
+
path: { type: 'string', description: 'Database path to remove' },
|
|
196
|
+
},
|
|
197
|
+
required: ['path'],
|
|
198
|
+
},
|
|
199
|
+
handler: async (args: Record<string, unknown>) => {
|
|
200
|
+
try {
|
|
201
|
+
const path = validateRtdbPath(args['path'] as string);
|
|
202
|
+
|
|
203
|
+
const db = getRealtimeDb();
|
|
204
|
+
await db.ref(path).remove();
|
|
205
|
+
|
|
206
|
+
return formatSuccess({
|
|
207
|
+
path,
|
|
208
|
+
message: `Data removed successfully at "${path}".`,
|
|
209
|
+
});
|
|
210
|
+
} catch (err) {
|
|
211
|
+
handleFirebaseError(err, 'rtdb', 'remove_data');
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
// ── rtdb_query_data ───────────────────────────────────
|
|
217
|
+
{
|
|
218
|
+
name: 'rtdb_query_data',
|
|
219
|
+
description:
|
|
220
|
+
'Query data in Realtime Database with ordering and filtering.',
|
|
221
|
+
inputSchema: {
|
|
222
|
+
type: 'object' as const,
|
|
223
|
+
properties: {
|
|
224
|
+
path: { type: 'string', description: 'Database path to query' },
|
|
225
|
+
orderBy: {
|
|
226
|
+
type: 'object',
|
|
227
|
+
properties: {
|
|
228
|
+
child: { type: 'string', description: 'Order by child key' },
|
|
229
|
+
key: { type: 'boolean', description: 'Order by key' },
|
|
230
|
+
value: { type: 'boolean', description: 'Order by value' },
|
|
231
|
+
},
|
|
232
|
+
description: 'Order specification (provide one of child, key, or value)',
|
|
233
|
+
},
|
|
234
|
+
startAt: { description: 'Start at this value (inclusive)' },
|
|
235
|
+
endAt: { description: 'End at this value (inclusive)' },
|
|
236
|
+
equalTo: { description: 'Filter to exact value' },
|
|
237
|
+
limitToFirst: { type: 'number', description: 'Limit to first N results' },
|
|
238
|
+
limitToLast: { type: 'number', description: 'Limit to last N results' },
|
|
239
|
+
},
|
|
240
|
+
required: ['path'],
|
|
241
|
+
},
|
|
242
|
+
handler: async (args: Record<string, unknown>) => {
|
|
243
|
+
try {
|
|
244
|
+
const path = validateRtdbPath(args['path'] as string);
|
|
245
|
+
const orderBy = args['orderBy'] as { child?: string; key?: boolean; value?: boolean } | undefined;
|
|
246
|
+
const startAt = args['startAt'] as string | number | boolean | null;
|
|
247
|
+
const endAt = args['endAt'] as string | number | boolean | null;
|
|
248
|
+
const equalTo = args['equalTo'] as string | number | boolean | null;
|
|
249
|
+
const limitToFirst = args['limitToFirst'] as number | undefined;
|
|
250
|
+
const limitToLast = args['limitToLast'] as number | undefined;
|
|
251
|
+
|
|
252
|
+
const db = getRealtimeDb();
|
|
253
|
+
let query: Query = db.ref(path);
|
|
254
|
+
|
|
255
|
+
// Apply ordering
|
|
256
|
+
if (orderBy?.child) {
|
|
257
|
+
query = query.orderByChild(orderBy.child);
|
|
258
|
+
} else if (orderBy?.key) {
|
|
259
|
+
query = query.orderByKey();
|
|
260
|
+
} else if (orderBy?.value) {
|
|
261
|
+
query = query.orderByValue();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Apply filters
|
|
265
|
+
if (equalTo !== undefined) {
|
|
266
|
+
query = query.equalTo(equalTo as string | number | boolean | null);
|
|
267
|
+
}
|
|
268
|
+
if (startAt !== undefined) {
|
|
269
|
+
query = query.startAt(startAt as string | number | boolean | null);
|
|
270
|
+
}
|
|
271
|
+
if (endAt !== undefined) {
|
|
272
|
+
query = query.endAt(endAt as string | number | boolean | null);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Apply limits
|
|
276
|
+
if (limitToFirst !== undefined) {
|
|
277
|
+
if (limitToFirst < 1 || limitToFirst > 10000) {
|
|
278
|
+
throw new Error('limitToFirst must be between 1 and 10000.');
|
|
279
|
+
}
|
|
280
|
+
query = query.limitToFirst(limitToFirst);
|
|
281
|
+
}
|
|
282
|
+
if (limitToLast !== undefined) {
|
|
283
|
+
if (limitToLast < 1 || limitToLast > 10000) {
|
|
284
|
+
throw new Error('limitToLast must be between 1 and 10000.');
|
|
285
|
+
}
|
|
286
|
+
query = query.limitToLast(limitToLast);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const snapshot = await query.once('value');
|
|
290
|
+
|
|
291
|
+
const results: Array<{ key: string; value: unknown }> = [];
|
|
292
|
+
snapshot.forEach((child) => {
|
|
293
|
+
results.push({ key: child.key as string, value: child.val() });
|
|
294
|
+
return false;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
return formatSuccess({
|
|
298
|
+
path,
|
|
299
|
+
count: results.length,
|
|
300
|
+
data: results,
|
|
301
|
+
});
|
|
302
|
+
} catch (err) {
|
|
303
|
+
handleFirebaseError(err, 'rtdb', 'query_data');
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
];
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { getStorage } from '../services/firebase.js';
|
|
2
|
+
import {
|
|
3
|
+
validateStoragePath,
|
|
4
|
+
handleFirebaseError,
|
|
5
|
+
formatSuccess,
|
|
6
|
+
formatListResult,
|
|
7
|
+
} from '../utils/index.js';
|
|
8
|
+
import type { ToolDefinition } from './types.js';
|
|
9
|
+
|
|
10
|
+
// ============================================================
|
|
11
|
+
// STORAGE TOOLS
|
|
12
|
+
// ============================================================
|
|
13
|
+
|
|
14
|
+
export const storageTools: ToolDefinition[] = [
|
|
15
|
+
// ── storage_upload_file ───────────────────────────────
|
|
16
|
+
{
|
|
17
|
+
name: 'storage_upload_file',
|
|
18
|
+
description:
|
|
19
|
+
'Upload a file to Firebase Cloud Storage. Provide base64-encoded file content.',
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: 'object' as const,
|
|
22
|
+
properties: {
|
|
23
|
+
path: { type: 'string', description: 'Destination path in Storage (e.g., "images/photo.jpg")' },
|
|
24
|
+
contentBase64: { type: 'string', description: 'Base64-encoded file content' },
|
|
25
|
+
contentType: { type: 'string', description: 'MIME type (e.g., "image/png"). Auto-detected if not provided.' },
|
|
26
|
+
metadata: { type: 'object', description: 'Additional metadata key-value pairs' },
|
|
27
|
+
public: { type: 'boolean', description: 'Make file publicly accessible (default: false)' },
|
|
28
|
+
},
|
|
29
|
+
required: ['path', 'contentBase64'],
|
|
30
|
+
},
|
|
31
|
+
handler: async (args: Record<string, unknown>) => {
|
|
32
|
+
try {
|
|
33
|
+
const destinationPath = validateStoragePath(args['path'] as string);
|
|
34
|
+
const contentBase64 = (args['contentBase64'] as string).trim();
|
|
35
|
+
const contentType = args['contentType'] as string | undefined;
|
|
36
|
+
const metadata = args['metadata'] as Record<string, string> | undefined;
|
|
37
|
+
const makePublic = (args['public'] as boolean) || false;
|
|
38
|
+
|
|
39
|
+
if (!contentBase64) {
|
|
40
|
+
throw new Error('contentBase64 cannot be empty.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const buffer = Buffer.from(contentBase64, 'base64');
|
|
44
|
+
|
|
45
|
+
const storage = getStorage();
|
|
46
|
+
const bucket = storage.bucket();
|
|
47
|
+
const file = bucket.file(destinationPath);
|
|
48
|
+
|
|
49
|
+
const uploadMetadata: Record<string, unknown> = {};
|
|
50
|
+
if (contentType) {
|
|
51
|
+
uploadMetadata.contentType = contentType;
|
|
52
|
+
}
|
|
53
|
+
if (metadata) {
|
|
54
|
+
uploadMetadata.metadata = metadata;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await file.save(buffer, {
|
|
58
|
+
metadata: uploadMetadata,
|
|
59
|
+
resumable: false,
|
|
60
|
+
validation: 'crc32c',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (makePublic) {
|
|
64
|
+
await file.makePublic();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const [url] = await file.getSignedUrl({
|
|
68
|
+
action: 'read',
|
|
69
|
+
expires: Date.now() + 60 * 60 * 1000,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return formatSuccess({
|
|
73
|
+
path: destinationPath,
|
|
74
|
+
size: buffer.length,
|
|
75
|
+
contentType: contentType || 'application/octet-stream',
|
|
76
|
+
public: makePublic,
|
|
77
|
+
signedUrl: url,
|
|
78
|
+
message: `File uploaded to "${destinationPath}" (${buffer.length} bytes).`,
|
|
79
|
+
});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
handleFirebaseError(err, 'storage', 'upload_file');
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// ── storage_download_file ─────────────────────────────
|
|
87
|
+
{
|
|
88
|
+
name: 'storage_download_file',
|
|
89
|
+
description: 'Download a file from Firebase Cloud Storage. Returns base64-encoded content.',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object' as const,
|
|
92
|
+
properties: {
|
|
93
|
+
path: { type: 'string', description: 'File path in Storage' },
|
|
94
|
+
},
|
|
95
|
+
required: ['path'],
|
|
96
|
+
},
|
|
97
|
+
handler: async (args: Record<string, unknown>) => {
|
|
98
|
+
try {
|
|
99
|
+
const filePath = validateStoragePath(args['path'] as string);
|
|
100
|
+
|
|
101
|
+
const storage = getStorage();
|
|
102
|
+
const bucket = storage.bucket();
|
|
103
|
+
const file = bucket.file(filePath);
|
|
104
|
+
|
|
105
|
+
const [exists] = await file.exists();
|
|
106
|
+
if (!exists) {
|
|
107
|
+
return formatSuccess({
|
|
108
|
+
exists: false,
|
|
109
|
+
path: filePath,
|
|
110
|
+
message: `File "${filePath}" does not exist.`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const [buffer] = await file.download();
|
|
115
|
+
const [metadata] = await file.getMetadata();
|
|
116
|
+
|
|
117
|
+
return formatSuccess({
|
|
118
|
+
exists: true,
|
|
119
|
+
path: filePath,
|
|
120
|
+
size: buffer.length,
|
|
121
|
+
contentType: (metadata.contentType as string) || 'application/octet-stream',
|
|
122
|
+
updated: metadata.updated as string,
|
|
123
|
+
contentBase64: buffer.toString('base64'),
|
|
124
|
+
});
|
|
125
|
+
} catch (err) {
|
|
126
|
+
handleFirebaseError(err, 'storage', 'download_file');
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
// ── storage_list_files ────────────────────────────────
|
|
132
|
+
{
|
|
133
|
+
name: 'storage_list_files',
|
|
134
|
+
description:
|
|
135
|
+
'List files in a Firebase Cloud Storage bucket. Optionally filter by prefix.',
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object' as const,
|
|
138
|
+
properties: {
|
|
139
|
+
prefix: { type: 'string', description: 'Filter files by prefix (e.g., "images/")' },
|
|
140
|
+
pageSize: { type: 'number', description: 'Maximum results (1-1000, default 100)' },
|
|
141
|
+
pageToken: { type: 'string', description: 'Pagination token from previous result' },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
handler: async (args: Record<string, unknown>) => {
|
|
145
|
+
try {
|
|
146
|
+
const prefix = (args['prefix'] as string) || '';
|
|
147
|
+
const pageSize = Math.min(Math.max((args['pageSize'] as number) || 100, 1), 1000);
|
|
148
|
+
const pageToken = args['pageToken'] as string | undefined;
|
|
149
|
+
|
|
150
|
+
const storage = getStorage();
|
|
151
|
+
const bucket = storage.bucket();
|
|
152
|
+
|
|
153
|
+
const options: { prefix: string; maxResults: number; pageToken?: string; autoPaginate: boolean } = {
|
|
154
|
+
prefix,
|
|
155
|
+
maxResults: pageSize,
|
|
156
|
+
autoPaginate: false,
|
|
157
|
+
};
|
|
158
|
+
if (pageToken) {
|
|
159
|
+
options.pageToken = pageToken;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const [files, nextQuery] = await bucket.getFiles(options);
|
|
163
|
+
|
|
164
|
+
const fileList = files.map((f) => ({
|
|
165
|
+
name: f.name,
|
|
166
|
+
size: f.metadata.size ? parseInt(f.metadata.size as string, 10) : 0,
|
|
167
|
+
contentType: f.metadata.contentType as string | undefined,
|
|
168
|
+
updated: f.metadata.updated as string | undefined,
|
|
169
|
+
timeCreated: f.metadata.timeCreated as string | undefined,
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
return formatListResult(fileList, nextQuery?.pageToken as string | undefined);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
handleFirebaseError(err, 'storage', 'list_files');
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
// ── storage_delete_file ───────────────────────────────
|
|
180
|
+
{
|
|
181
|
+
name: 'storage_delete_file',
|
|
182
|
+
description: 'Delete a file from Firebase Cloud Storage.',
|
|
183
|
+
inputSchema: {
|
|
184
|
+
type: 'object' as const,
|
|
185
|
+
properties: {
|
|
186
|
+
path: { type: 'string', description: 'File path to delete' },
|
|
187
|
+
},
|
|
188
|
+
required: ['path'],
|
|
189
|
+
},
|
|
190
|
+
handler: async (args: Record<string, unknown>) => {
|
|
191
|
+
try {
|
|
192
|
+
const filePath = validateStoragePath(args['path'] as string);
|
|
193
|
+
|
|
194
|
+
const storage = getStorage();
|
|
195
|
+
const bucket = storage.bucket();
|
|
196
|
+
const file = bucket.file(filePath);
|
|
197
|
+
|
|
198
|
+
const [exists] = await file.exists();
|
|
199
|
+
if (!exists) {
|
|
200
|
+
return formatSuccess({
|
|
201
|
+
success: false,
|
|
202
|
+
path: filePath,
|
|
203
|
+
message: `File "${filePath}" does not exist.`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await file.delete();
|
|
208
|
+
|
|
209
|
+
return formatSuccess({
|
|
210
|
+
path: filePath,
|
|
211
|
+
message: `File "${filePath}" deleted successfully.`,
|
|
212
|
+
});
|
|
213
|
+
} catch (err) {
|
|
214
|
+
handleFirebaseError(err, 'storage', 'delete_file');
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
// ── storage_get_signed_url ────────────────────────────
|
|
220
|
+
{
|
|
221
|
+
name: 'storage_get_signed_url',
|
|
222
|
+
description:
|
|
223
|
+
'Generate a signed URL for a file in Firebase Cloud Storage.',
|
|
224
|
+
inputSchema: {
|
|
225
|
+
type: 'object' as const,
|
|
226
|
+
properties: {
|
|
227
|
+
path: { type: 'string', description: 'File path' },
|
|
228
|
+
action: { type: 'string', enum: ['read', 'write', 'delete'], description: 'Action for the signed URL (default: read)' },
|
|
229
|
+
expiresInMs: { type: 'number', description: 'URL expiration in milliseconds (default: 3600000 = 1 hour)' },
|
|
230
|
+
},
|
|
231
|
+
required: ['path'],
|
|
232
|
+
},
|
|
233
|
+
handler: async (args: Record<string, unknown>) => {
|
|
234
|
+
try {
|
|
235
|
+
const filePath = validateStoragePath(args['path'] as string);
|
|
236
|
+
const action = (args['action'] as 'read' | 'write' | 'delete') || 'read';
|
|
237
|
+
const expiresInMs = (args['expiresInMs'] as number) || 3600000;
|
|
238
|
+
|
|
239
|
+
if (expiresInMs < 60000) {
|
|
240
|
+
throw new Error('Signed URL must be valid for at least 60 seconds.');
|
|
241
|
+
}
|
|
242
|
+
if (expiresInMs > 7 * 24 * 3600000) {
|
|
243
|
+
throw new Error('Signed URL cannot be valid for more than 7 days.');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const storage = getStorage();
|
|
247
|
+
const bucket = storage.bucket();
|
|
248
|
+
const file = bucket.file(filePath);
|
|
249
|
+
|
|
250
|
+
const [exists] = await file.exists();
|
|
251
|
+
if (!exists) {
|
|
252
|
+
return formatSuccess({
|
|
253
|
+
exists: false,
|
|
254
|
+
path: filePath,
|
|
255
|
+
message: `File "${filePath}" does not exist.`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const [url] = await file.getSignedUrl({
|
|
260
|
+
action,
|
|
261
|
+
expires: Date.now() + expiresInMs,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return formatSuccess({
|
|
265
|
+
path: filePath,
|
|
266
|
+
action,
|
|
267
|
+
signedUrl: url,
|
|
268
|
+
expiresAt: new Date(Date.now() + expiresInMs).toISOString(),
|
|
269
|
+
});
|
|
270
|
+
} catch (err) {
|
|
271
|
+
handleFirebaseError(err, 'storage', 'get_signed_url');
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
// ── storage_get_metadata ──────────────────────────────
|
|
277
|
+
{
|
|
278
|
+
name: 'storage_get_metadata',
|
|
279
|
+
description: 'Get metadata for a file in Firebase Cloud Storage.',
|
|
280
|
+
inputSchema: {
|
|
281
|
+
type: 'object' as const,
|
|
282
|
+
properties: {
|
|
283
|
+
path: { type: 'string', description: 'File path' },
|
|
284
|
+
},
|
|
285
|
+
required: ['path'],
|
|
286
|
+
},
|
|
287
|
+
handler: async (args: Record<string, unknown>) => {
|
|
288
|
+
try {
|
|
289
|
+
const filePath = validateStoragePath(args['path'] as string);
|
|
290
|
+
|
|
291
|
+
const storage = getStorage();
|
|
292
|
+
const bucket = storage.bucket();
|
|
293
|
+
const file = bucket.file(filePath);
|
|
294
|
+
|
|
295
|
+
const [metadata] = await file.getMetadata();
|
|
296
|
+
|
|
297
|
+
return formatSuccess({
|
|
298
|
+
name: metadata.name,
|
|
299
|
+
bucket: metadata.bucket,
|
|
300
|
+
size: metadata.size ? parseInt(metadata.size as string, 10) : 0,
|
|
301
|
+
contentType: metadata.contentType as string | undefined,
|
|
302
|
+
timeCreated: metadata.timeCreated as string | undefined,
|
|
303
|
+
updated: metadata.updated as string | undefined,
|
|
304
|
+
storageClass: metadata.storageClass as string | undefined,
|
|
305
|
+
md5Hash: metadata.md5Hash as string | undefined,
|
|
306
|
+
crc32c: metadata.crc32c as string | undefined,
|
|
307
|
+
customMetadata: metadata.metadata || {},
|
|
308
|
+
});
|
|
309
|
+
} catch (err) {
|
|
310
|
+
handleFirebaseError(err, 'storage', 'get_metadata');
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
];
|