@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,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FirebaseToolError = void 0;
|
|
4
|
+
exports.handleFirebaseError = handleFirebaseError;
|
|
5
|
+
exports.formatSuccess = formatSuccess;
|
|
6
|
+
exports.formatListResult = formatListResult;
|
|
7
|
+
class FirebaseToolError extends Error {
|
|
8
|
+
context;
|
|
9
|
+
constructor(message, context) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'FirebaseToolError';
|
|
12
|
+
this.context = context;
|
|
13
|
+
}
|
|
14
|
+
toStructuredMessage() {
|
|
15
|
+
const lines = [
|
|
16
|
+
`[${this.context.service}/${this.context.operation}] ${this.message}`,
|
|
17
|
+
];
|
|
18
|
+
if (this.context.details) {
|
|
19
|
+
lines.push(` Details: ${this.context.details}`);
|
|
20
|
+
}
|
|
21
|
+
if (this.context.suggestion) {
|
|
22
|
+
lines.push(` Suggestion: ${this.context.suggestion}`);
|
|
23
|
+
}
|
|
24
|
+
return lines.join('\n');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.FirebaseToolError = FirebaseToolError;
|
|
28
|
+
function handleFirebaseError(error, service, operation) {
|
|
29
|
+
if (error instanceof FirebaseToolError) {
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
const err = error;
|
|
33
|
+
let suggestion;
|
|
34
|
+
let details;
|
|
35
|
+
// Map common Firebase error codes to actionable suggestions
|
|
36
|
+
if (err.code) {
|
|
37
|
+
switch (err.code) {
|
|
38
|
+
case 'permission-denied':
|
|
39
|
+
case 'PERMISSION_DENIED':
|
|
40
|
+
suggestion = 'Check that your service account has the required IAM permissions for this operation.';
|
|
41
|
+
break;
|
|
42
|
+
case 'not-found':
|
|
43
|
+
case 'NOT_FOUND':
|
|
44
|
+
suggestion = 'Verify that the resource (document, collection, user, or file) exists and the path is correct.';
|
|
45
|
+
break;
|
|
46
|
+
case 'already-exists':
|
|
47
|
+
case 'ALREADY_EXISTS':
|
|
48
|
+
suggestion = 'A resource with this identifier already exists. Use update instead of create, or provide a different ID.';
|
|
49
|
+
break;
|
|
50
|
+
case 'invalid-argument':
|
|
51
|
+
case 'INVALID_ARGUMENT':
|
|
52
|
+
suggestion = 'Check that all required fields are provided and values are in the correct format.';
|
|
53
|
+
break;
|
|
54
|
+
case 'resource-exhausted':
|
|
55
|
+
case 'RESOURCE_EXHAUSTED':
|
|
56
|
+
suggestion = 'Firebase quota exceeded. Wait and retry, or upgrade your Firebase plan.';
|
|
57
|
+
break;
|
|
58
|
+
case 'unauthenticated':
|
|
59
|
+
case 'UNAUTHENTICATED':
|
|
60
|
+
suggestion = 'Service account authentication failed. Verify FIREBASE_SERVICE_ACCOUNT_PATH or FIREBASE_SERVICE_ACCOUNT_KEY is set correctly.';
|
|
61
|
+
break;
|
|
62
|
+
case 'unavailable':
|
|
63
|
+
case 'UNAVAILABLE':
|
|
64
|
+
suggestion = 'Firebase service is temporarily unavailable. Retry with exponential backoff.';
|
|
65
|
+
break;
|
|
66
|
+
case 'deadline-exceeded':
|
|
67
|
+
case 'DEADLINE_EXCEEDED':
|
|
68
|
+
suggestion = 'Operation timed out. Try reducing the data size or splitting into smaller batches.';
|
|
69
|
+
break;
|
|
70
|
+
default:
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
details = err.message;
|
|
75
|
+
throw new FirebaseToolError(err.message || 'An unknown error occurred.', { service, operation, suggestion, details });
|
|
76
|
+
}
|
|
77
|
+
function formatSuccess(data, operation) {
|
|
78
|
+
return JSON.stringify({
|
|
79
|
+
success: true,
|
|
80
|
+
...(operation && { operation }),
|
|
81
|
+
data,
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
}, null, 2);
|
|
84
|
+
}
|
|
85
|
+
function formatListResult(items, pageToken, totalCount) {
|
|
86
|
+
return JSON.stringify({
|
|
87
|
+
success: true,
|
|
88
|
+
count: items.length,
|
|
89
|
+
...(totalCount !== undefined && { totalCount }),
|
|
90
|
+
...(pageToken && { nextPageToken: pageToken }),
|
|
91
|
+
items,
|
|
92
|
+
}, null, 2);
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { validateCollectionPath, validateDocumentPath, validateStoragePath, validateEmail, validateUid, validateLimit, validatePageSize, validateWhereField, validateOperator, sanitizeData, CollectionPathSchema, DocumentPathSchema, StoragePathSchema, UidSchema, EmailSchema, DataSchema, LimitSchema, PageSizeSchema, } from './validation.js';
|
|
2
|
+
export { FirebaseToolError, handleFirebaseError, formatSuccess, formatListResult, } from './errors.js';
|
|
3
|
+
export { LRUCache, firestoreCache, schemaCache } from './cache.js';
|
|
4
|
+
export { paginatedQuery, fetchAllDocuments, encodePageToken, decodePageToken, } from './pagination.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.decodePageToken = exports.encodePageToken = exports.fetchAllDocuments = exports.paginatedQuery = exports.schemaCache = exports.firestoreCache = exports.LRUCache = exports.formatListResult = exports.formatSuccess = exports.handleFirebaseError = exports.FirebaseToolError = exports.PageSizeSchema = exports.LimitSchema = exports.DataSchema = exports.EmailSchema = exports.UidSchema = exports.StoragePathSchema = exports.DocumentPathSchema = exports.CollectionPathSchema = exports.sanitizeData = exports.validateOperator = exports.validateWhereField = exports.validatePageSize = exports.validateLimit = exports.validateUid = exports.validateEmail = exports.validateStoragePath = exports.validateDocumentPath = exports.validateCollectionPath = void 0;
|
|
4
|
+
var validation_js_1 = require("./validation.js");
|
|
5
|
+
Object.defineProperty(exports, "validateCollectionPath", { enumerable: true, get: function () { return validation_js_1.validateCollectionPath; } });
|
|
6
|
+
Object.defineProperty(exports, "validateDocumentPath", { enumerable: true, get: function () { return validation_js_1.validateDocumentPath; } });
|
|
7
|
+
Object.defineProperty(exports, "validateStoragePath", { enumerable: true, get: function () { return validation_js_1.validateStoragePath; } });
|
|
8
|
+
Object.defineProperty(exports, "validateEmail", { enumerable: true, get: function () { return validation_js_1.validateEmail; } });
|
|
9
|
+
Object.defineProperty(exports, "validateUid", { enumerable: true, get: function () { return validation_js_1.validateUid; } });
|
|
10
|
+
Object.defineProperty(exports, "validateLimit", { enumerable: true, get: function () { return validation_js_1.validateLimit; } });
|
|
11
|
+
Object.defineProperty(exports, "validatePageSize", { enumerable: true, get: function () { return validation_js_1.validatePageSize; } });
|
|
12
|
+
Object.defineProperty(exports, "validateWhereField", { enumerable: true, get: function () { return validation_js_1.validateWhereField; } });
|
|
13
|
+
Object.defineProperty(exports, "validateOperator", { enumerable: true, get: function () { return validation_js_1.validateOperator; } });
|
|
14
|
+
Object.defineProperty(exports, "sanitizeData", { enumerable: true, get: function () { return validation_js_1.sanitizeData; } });
|
|
15
|
+
Object.defineProperty(exports, "CollectionPathSchema", { enumerable: true, get: function () { return validation_js_1.CollectionPathSchema; } });
|
|
16
|
+
Object.defineProperty(exports, "DocumentPathSchema", { enumerable: true, get: function () { return validation_js_1.DocumentPathSchema; } });
|
|
17
|
+
Object.defineProperty(exports, "StoragePathSchema", { enumerable: true, get: function () { return validation_js_1.StoragePathSchema; } });
|
|
18
|
+
Object.defineProperty(exports, "UidSchema", { enumerable: true, get: function () { return validation_js_1.UidSchema; } });
|
|
19
|
+
Object.defineProperty(exports, "EmailSchema", { enumerable: true, get: function () { return validation_js_1.EmailSchema; } });
|
|
20
|
+
Object.defineProperty(exports, "DataSchema", { enumerable: true, get: function () { return validation_js_1.DataSchema; } });
|
|
21
|
+
Object.defineProperty(exports, "LimitSchema", { enumerable: true, get: function () { return validation_js_1.LimitSchema; } });
|
|
22
|
+
Object.defineProperty(exports, "PageSizeSchema", { enumerable: true, get: function () { return validation_js_1.PageSizeSchema; } });
|
|
23
|
+
var errors_js_1 = require("./errors.js");
|
|
24
|
+
Object.defineProperty(exports, "FirebaseToolError", { enumerable: true, get: function () { return errors_js_1.FirebaseToolError; } });
|
|
25
|
+
Object.defineProperty(exports, "handleFirebaseError", { enumerable: true, get: function () { return errors_js_1.handleFirebaseError; } });
|
|
26
|
+
Object.defineProperty(exports, "formatSuccess", { enumerable: true, get: function () { return errors_js_1.formatSuccess; } });
|
|
27
|
+
Object.defineProperty(exports, "formatListResult", { enumerable: true, get: function () { return errors_js_1.formatListResult; } });
|
|
28
|
+
var cache_js_1 = require("./cache.js");
|
|
29
|
+
Object.defineProperty(exports, "LRUCache", { enumerable: true, get: function () { return cache_js_1.LRUCache; } });
|
|
30
|
+
Object.defineProperty(exports, "firestoreCache", { enumerable: true, get: function () { return cache_js_1.firestoreCache; } });
|
|
31
|
+
Object.defineProperty(exports, "schemaCache", { enumerable: true, get: function () { return cache_js_1.schemaCache; } });
|
|
32
|
+
var pagination_js_1 = require("./pagination.js");
|
|
33
|
+
Object.defineProperty(exports, "paginatedQuery", { enumerable: true, get: function () { return pagination_js_1.paginatedQuery; } });
|
|
34
|
+
Object.defineProperty(exports, "fetchAllDocuments", { enumerable: true, get: function () { return pagination_js_1.fetchAllDocuments; } });
|
|
35
|
+
Object.defineProperty(exports, "encodePageToken", { enumerable: true, get: function () { return pagination_js_1.encodePageToken; } });
|
|
36
|
+
Object.defineProperty(exports, "decodePageToken", { enumerable: true, get: function () { return pagination_js_1.decodePageToken; } });
|
|
37
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Query, DocumentSnapshot } from 'firebase-admin/firestore';
|
|
2
|
+
export interface PaginationOptions {
|
|
3
|
+
pageSize: number;
|
|
4
|
+
pageToken?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface PaginatedResult<T> {
|
|
7
|
+
items: T[];
|
|
8
|
+
nextPageToken?: string;
|
|
9
|
+
hasMore: boolean;
|
|
10
|
+
totalCount: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Execute a paginated Firestore query.
|
|
14
|
+
*/
|
|
15
|
+
export declare function paginatedQuery<T>(baseQuery: Query, options: PaginationOptions, transform: (snap: DocumentSnapshot) => T): Promise<PaginatedResult<T>>;
|
|
16
|
+
/**
|
|
17
|
+
* Fetch all documents from a collection with automatic pagination.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fetchAllDocuments<T>(baseQuery: Query, transform: (snap: DocumentSnapshot) => T, pageSize?: number, maxTotal?: number): Promise<T[]>;
|
|
20
|
+
/**
|
|
21
|
+
* Encode a document snapshot as a page token.
|
|
22
|
+
*/
|
|
23
|
+
export declare function encodePageToken(docPath: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Decode a page token back to a document path.
|
|
26
|
+
*/
|
|
27
|
+
export declare function decodePageToken(token: string): string;
|
|
28
|
+
//# sourceMappingURL=pagination.d.ts.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.paginatedQuery = paginatedQuery;
|
|
4
|
+
exports.fetchAllDocuments = fetchAllDocuments;
|
|
5
|
+
exports.encodePageToken = encodePageToken;
|
|
6
|
+
exports.decodePageToken = decodePageToken;
|
|
7
|
+
const validation_js_1 = require("./validation.js");
|
|
8
|
+
/**
|
|
9
|
+
* Execute a paginated Firestore query.
|
|
10
|
+
*/
|
|
11
|
+
async function paginatedQuery(baseQuery, options, transform) {
|
|
12
|
+
const pageSize = (0, validation_js_1.validatePageSize)(options.pageSize);
|
|
13
|
+
let query = baseQuery.limit(pageSize);
|
|
14
|
+
const allItems = [];
|
|
15
|
+
let lastDoc;
|
|
16
|
+
if (options.pageToken) {
|
|
17
|
+
const decodedToken = Buffer.from(options.pageToken, 'base64').toString('utf-8');
|
|
18
|
+
const lastDocRef = baseQuery.firestore.doc(decodedToken);
|
|
19
|
+
const lastDocSnap = await lastDocRef.get();
|
|
20
|
+
if (lastDocSnap.exists) {
|
|
21
|
+
query = query.startAfter(lastDocSnap);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const snapshot = await query.get();
|
|
25
|
+
for (const doc of snapshot.docs) {
|
|
26
|
+
allItems.push(transform(doc));
|
|
27
|
+
lastDoc = doc;
|
|
28
|
+
}
|
|
29
|
+
const hasMore = snapshot.size === pageSize;
|
|
30
|
+
let nextToken;
|
|
31
|
+
if (hasMore && lastDoc) {
|
|
32
|
+
nextToken = Buffer.from(lastDoc.ref.path).toString('base64');
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
items: allItems,
|
|
36
|
+
nextPageToken: nextToken,
|
|
37
|
+
hasMore,
|
|
38
|
+
totalCount: allItems.length,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Fetch all documents from a collection with automatic pagination.
|
|
43
|
+
*/
|
|
44
|
+
async function fetchAllDocuments(baseQuery, transform, pageSize = 500, maxTotal = 10000) {
|
|
45
|
+
let query = baseQuery.limit(pageSize);
|
|
46
|
+
const allItems = [];
|
|
47
|
+
let lastDoc;
|
|
48
|
+
while (allItems.length < maxTotal) {
|
|
49
|
+
if (lastDoc) {
|
|
50
|
+
query = baseQuery.limit(pageSize).startAfter(lastDoc);
|
|
51
|
+
}
|
|
52
|
+
const snapshot = await query.get();
|
|
53
|
+
for (const doc of snapshot.docs) {
|
|
54
|
+
allItems.push(transform(doc));
|
|
55
|
+
lastDoc = doc;
|
|
56
|
+
}
|
|
57
|
+
if (snapshot.size < pageSize) {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return allItems;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Encode a document snapshot as a page token.
|
|
65
|
+
*/
|
|
66
|
+
function encodePageToken(docPath) {
|
|
67
|
+
return Buffer.from(docPath).toString('base64');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Decode a page token back to a document path.
|
|
71
|
+
*/
|
|
72
|
+
function decodePageToken(token) {
|
|
73
|
+
return Buffer.from(token, 'base64').toString('utf-8');
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=pagination.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const pathSegmentRegex: RegExp;
|
|
3
|
+
export declare const collectionPathRegex: RegExp;
|
|
4
|
+
export declare function validateCollectionPath(path: string): string;
|
|
5
|
+
export declare function validateDocumentPath(path: string): string;
|
|
6
|
+
export declare function validateStoragePath(path: string): string;
|
|
7
|
+
export declare function validateEmail(email: string): string;
|
|
8
|
+
export declare function validateUid(uid: string): string;
|
|
9
|
+
export declare function validateLimit(limit: number, max?: number): number;
|
|
10
|
+
export declare function validatePageSize(size: number, max?: number): number;
|
|
11
|
+
export declare function validateWhereField(field: string): string;
|
|
12
|
+
export declare function validateOperator(op: string): string;
|
|
13
|
+
export declare function sanitizeData(data: unknown): unknown;
|
|
14
|
+
export declare const CollectionPathSchema: z.ZodString;
|
|
15
|
+
export declare const DocumentPathSchema: z.ZodString;
|
|
16
|
+
export declare const StoragePathSchema: z.ZodString;
|
|
17
|
+
export declare const UidSchema: z.ZodString;
|
|
18
|
+
export declare const EmailSchema: z.ZodString;
|
|
19
|
+
export declare const DataSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
20
|
+
export declare const LimitSchema: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
21
|
+
export declare const PageSizeSchema: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
22
|
+
//# sourceMappingURL=validation.d.ts.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PageSizeSchema = exports.LimitSchema = exports.DataSchema = exports.EmailSchema = exports.UidSchema = exports.StoragePathSchema = exports.DocumentPathSchema = exports.CollectionPathSchema = exports.collectionPathRegex = exports.pathSegmentRegex = void 0;
|
|
4
|
+
exports.validateCollectionPath = validateCollectionPath;
|
|
5
|
+
exports.validateDocumentPath = validateDocumentPath;
|
|
6
|
+
exports.validateStoragePath = validateStoragePath;
|
|
7
|
+
exports.validateEmail = validateEmail;
|
|
8
|
+
exports.validateUid = validateUid;
|
|
9
|
+
exports.validateLimit = validateLimit;
|
|
10
|
+
exports.validatePageSize = validatePageSize;
|
|
11
|
+
exports.validateWhereField = validateWhereField;
|
|
12
|
+
exports.validateOperator = validateOperator;
|
|
13
|
+
exports.sanitizeData = sanitizeData;
|
|
14
|
+
const zod_1 = require("zod");
|
|
15
|
+
// Shared validation schemas and input sanitization utilities
|
|
16
|
+
exports.pathSegmentRegex = /^[a-zA-Z0-9_-]+$/;
|
|
17
|
+
exports.collectionPathRegex = /^(?:[a-zA-Z0-9_-]+\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+|[a-zA-Z0-9_-]+)$/;
|
|
18
|
+
function validateCollectionPath(path) {
|
|
19
|
+
const trimmed = path.trim();
|
|
20
|
+
if (trimmed.length === 0) {
|
|
21
|
+
throw new Error('Collection path cannot be empty.');
|
|
22
|
+
}
|
|
23
|
+
if (trimmed.length > 1500) {
|
|
24
|
+
throw new Error('Collection path exceeds maximum length of 1500 characters.');
|
|
25
|
+
}
|
|
26
|
+
if (!exports.collectionPathRegex.test(trimmed)) {
|
|
27
|
+
throw new Error(`Invalid collection path: "${trimmed}". ` +
|
|
28
|
+
'Path segments must contain only letters, numbers, hyphens, and underscores. ' +
|
|
29
|
+
'Use forward slashes to separate segments (e.g., "users" or "users/uid123/posts").');
|
|
30
|
+
}
|
|
31
|
+
return trimmed;
|
|
32
|
+
}
|
|
33
|
+
function validateDocumentPath(path) {
|
|
34
|
+
const trimmed = path.trim();
|
|
35
|
+
if (trimmed.length === 0) {
|
|
36
|
+
throw new Error('Document path cannot be empty.');
|
|
37
|
+
}
|
|
38
|
+
if (trimmed.length > 1500) {
|
|
39
|
+
throw new Error('Document path exceeds maximum length of 1500 characters.');
|
|
40
|
+
}
|
|
41
|
+
const segments = trimmed.split('/');
|
|
42
|
+
if (segments.length % 2 !== 0) {
|
|
43
|
+
throw new Error(`Invalid document path: "${trimmed}". ` +
|
|
44
|
+
'Document paths must have an even number of segments (e.g., "users/uid123" or "users/uid123/posts/post1").');
|
|
45
|
+
}
|
|
46
|
+
for (const seg of segments) {
|
|
47
|
+
if (!exports.pathSegmentRegex.test(seg)) {
|
|
48
|
+
throw new Error(`Invalid segment "${seg}" in document path. ` +
|
|
49
|
+
'Segments must contain only letters, numbers, hyphens, and underscores.');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return trimmed;
|
|
53
|
+
}
|
|
54
|
+
function validateStoragePath(path) {
|
|
55
|
+
const trimmed = path.trim();
|
|
56
|
+
if (trimmed.length === 0) {
|
|
57
|
+
throw new Error('Storage path cannot be empty.');
|
|
58
|
+
}
|
|
59
|
+
if (trimmed.startsWith('/')) {
|
|
60
|
+
throw new Error(`Invalid storage path: "${trimmed}". ` +
|
|
61
|
+
'Path must not start with "/" to prevent path traversal.');
|
|
62
|
+
}
|
|
63
|
+
if (trimmed.startsWith('..')) {
|
|
64
|
+
throw new Error(`Invalid storage path: "${trimmed}". ` +
|
|
65
|
+
'Path must not start with ".." to prevent path traversal.');
|
|
66
|
+
}
|
|
67
|
+
if (trimmed.includes('..')) {
|
|
68
|
+
throw new Error(`Invalid storage path: "${trimmed}". ` +
|
|
69
|
+
'Path must not contain ".." to prevent path traversal.');
|
|
70
|
+
}
|
|
71
|
+
return trimmed;
|
|
72
|
+
}
|
|
73
|
+
function validateEmail(email) {
|
|
74
|
+
const trimmed = email.trim().toLowerCase();
|
|
75
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
76
|
+
if (!emailRegex.test(trimmed)) {
|
|
77
|
+
throw new Error(`Invalid email address: "${email}". ` +
|
|
78
|
+
'Please provide a valid email (e.g., user@example.com).');
|
|
79
|
+
}
|
|
80
|
+
return trimmed;
|
|
81
|
+
}
|
|
82
|
+
function validateUid(uid) {
|
|
83
|
+
const trimmed = uid.trim();
|
|
84
|
+
if (trimmed.length === 0) {
|
|
85
|
+
throw new Error('UID cannot be empty.');
|
|
86
|
+
}
|
|
87
|
+
if (trimmed.length > 128) {
|
|
88
|
+
throw new Error('UID exceeds maximum length of 128 characters.');
|
|
89
|
+
}
|
|
90
|
+
return trimmed;
|
|
91
|
+
}
|
|
92
|
+
function validateLimit(limit, max = 10000) {
|
|
93
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
94
|
+
throw new Error(`Limit must be a positive integer, got: ${limit}.`);
|
|
95
|
+
}
|
|
96
|
+
if (limit > max) {
|
|
97
|
+
throw new Error(`Limit exceeds maximum of ${max}. Use pagination to retrieve more results.`);
|
|
98
|
+
}
|
|
99
|
+
return limit;
|
|
100
|
+
}
|
|
101
|
+
function validatePageSize(size, max = 1000) {
|
|
102
|
+
if (!Number.isInteger(size) || size < 1) {
|
|
103
|
+
throw new Error(`Page size must be a positive integer, got: ${size}.`);
|
|
104
|
+
}
|
|
105
|
+
if (size > max) {
|
|
106
|
+
throw new Error(`Page size exceeds maximum of ${max}.`);
|
|
107
|
+
}
|
|
108
|
+
return size;
|
|
109
|
+
}
|
|
110
|
+
function validateWhereField(field) {
|
|
111
|
+
const trimmed = field.trim();
|
|
112
|
+
if (trimmed.length === 0) {
|
|
113
|
+
throw new Error('Where clause field cannot be empty.');
|
|
114
|
+
}
|
|
115
|
+
// Prevent NoSQL injection: disallow field paths that try to access internals
|
|
116
|
+
if (trimmed.startsWith('__') || trimmed.includes('$')) {
|
|
117
|
+
throw new Error(`Invalid field name: "${trimmed}". ` +
|
|
118
|
+
'Field names must not start with "__" or contain "$".');
|
|
119
|
+
}
|
|
120
|
+
return trimmed;
|
|
121
|
+
}
|
|
122
|
+
function validateOperator(op) {
|
|
123
|
+
const allowed = [
|
|
124
|
+
'==', '!=', '<', '<=', '>', '>=',
|
|
125
|
+
'array-contains', 'array-contains-any', 'in', 'not-in',
|
|
126
|
+
];
|
|
127
|
+
if (!allowed.includes(op)) {
|
|
128
|
+
throw new Error(`Invalid operator: "${op}". ` +
|
|
129
|
+
`Allowed operators: ${allowed.join(', ')}.`);
|
|
130
|
+
}
|
|
131
|
+
return op;
|
|
132
|
+
}
|
|
133
|
+
function sanitizeData(data) {
|
|
134
|
+
if (data === null || data === undefined) {
|
|
135
|
+
return data;
|
|
136
|
+
}
|
|
137
|
+
if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') {
|
|
138
|
+
return data;
|
|
139
|
+
}
|
|
140
|
+
if (data instanceof Date) {
|
|
141
|
+
return data;
|
|
142
|
+
}
|
|
143
|
+
if (Array.isArray(data)) {
|
|
144
|
+
return data.map(sanitizeData);
|
|
145
|
+
}
|
|
146
|
+
if (typeof data === 'object') {
|
|
147
|
+
const sanitized = {};
|
|
148
|
+
for (const [key, value] of Object.entries(data)) {
|
|
149
|
+
if (key.startsWith('$')) {
|
|
150
|
+
throw new Error(`Invalid field key: "${key}". ` +
|
|
151
|
+
'Keys must not start with "$" to prevent NoSQL injection.');
|
|
152
|
+
}
|
|
153
|
+
if (key.includes('.')) {
|
|
154
|
+
throw new Error(`Invalid field key: "${key}". ` +
|
|
155
|
+
'Keys must not contain "." to prevent NoSQL injection.');
|
|
156
|
+
}
|
|
157
|
+
sanitized[key] = sanitizeData(value);
|
|
158
|
+
}
|
|
159
|
+
return sanitized;
|
|
160
|
+
}
|
|
161
|
+
return data;
|
|
162
|
+
}
|
|
163
|
+
// Common Zod schemas for tool parameters
|
|
164
|
+
exports.CollectionPathSchema = zod_1.z.string().min(1).describe('Firestore collection path (e.g., "users" or "users/uid123/orders")');
|
|
165
|
+
exports.DocumentPathSchema = zod_1.z.string().min(1).describe('Firestore document path (e.g., "users/uid123" or "users/uid123/orders/order1")');
|
|
166
|
+
exports.StoragePathSchema = zod_1.z.string().min(1).describe('Cloud Storage path (e.g., "images/photo.jpg")');
|
|
167
|
+
exports.UidSchema = zod_1.z.string().min(1).describe('Firebase Auth UID');
|
|
168
|
+
exports.EmailSchema = zod_1.z.string().email().describe('User email address');
|
|
169
|
+
exports.DataSchema = zod_1.z.record(zod_1.z.unknown()).describe('Document data as a JSON object');
|
|
170
|
+
exports.LimitSchema = zod_1.z.number().int().min(1).max(10000).optional().default(100).describe('Maximum number of results to return (1-10000, default: 100)');
|
|
171
|
+
exports.PageSizeSchema = zod_1.z.number().int().min(1).max(1000).optional().default(100).describe('Number of results per page (1-1000, default: 100)');
|
|
172
|
+
//# sourceMappingURL=validation.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theihtisham/mcp-server-firebase",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server giving AI assistants full Firebase/Firestore access",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-server-firebase": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsx src/index.ts",
|
|
13
|
+
"start": "node dist/index.js",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:watch": "vitest",
|
|
16
|
+
"test:coverage": "vitest run --coverage",
|
|
17
|
+
"lint": "tsc --noEmit",
|
|
18
|
+
"clean": "rimraf dist",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"firebase",
|
|
24
|
+
"firestore",
|
|
25
|
+
"ai",
|
|
26
|
+
"claude",
|
|
27
|
+
"model-context-protocol"
|
|
28
|
+
],
|
|
29
|
+
"author": "",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
33
|
+
"firebase-admin": "^12.7.0",
|
|
34
|
+
"zod": "^3.24.2"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^22.10.0",
|
|
38
|
+
"rimraf": "^6.0.1",
|
|
39
|
+
"tsx": "^4.19.0",
|
|
40
|
+
"typescript": "^5.7.0",
|
|
41
|
+
"vitest": "^2.1.0"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18.0.0"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "https://github.com/theihtisham/mcp-server-firebase"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import { initializeFirebase } from './services/firebase.js';
|
|
10
|
+
import { allTools } from './tools/index.js';
|
|
11
|
+
|
|
12
|
+
const SERVER_NAME = 'mcp-server-firebase';
|
|
13
|
+
const SERVER_VERSION = '1.0.0';
|
|
14
|
+
|
|
15
|
+
async function main(): Promise<void> {
|
|
16
|
+
// Validate Firebase configuration on startup
|
|
17
|
+
try {
|
|
18
|
+
initializeFirebase();
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.error(
|
|
21
|
+
`Failed to initialize Firebase: ${(err as Error).message}\n` +
|
|
22
|
+
'Ensure one of the following is set:\n' +
|
|
23
|
+
' - FIREBASE_SERVICE_ACCOUNT_KEY (JSON string)\n' +
|
|
24
|
+
' - FIREBASE_SERVICE_ACCOUNT_PATH (file path)\n' +
|
|
25
|
+
' - FIREBASE_PROJECT_ID + FIREBASE_CLIENT_EMAIL + FIREBASE_PRIVATE_KEY\n' +
|
|
26
|
+
' - Application Default Credentials (gcloud auth application-default login)'
|
|
27
|
+
);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Create MCP server using the low-level Server class
|
|
32
|
+
// This allows us to register tools with raw JSON Schema input schemas
|
|
33
|
+
const server = new Server(
|
|
34
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
35
|
+
{
|
|
36
|
+
capabilities: {
|
|
37
|
+
tools: {},
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Build a map for quick tool lookup
|
|
43
|
+
const toolMap = new Map(allTools.map((t) => [t.name, t]));
|
|
44
|
+
|
|
45
|
+
// Handle tools/list requests
|
|
46
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
47
|
+
return {
|
|
48
|
+
tools: allTools.map((tool) => ({
|
|
49
|
+
name: tool.name,
|
|
50
|
+
description: tool.description,
|
|
51
|
+
inputSchema: tool.inputSchema,
|
|
52
|
+
})),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Handle tools/call requests
|
|
57
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
58
|
+
const toolName = request.params.name;
|
|
59
|
+
const tool = toolMap.get(toolName);
|
|
60
|
+
|
|
61
|
+
if (!tool) {
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: 'text' as const, text: `Error: Unknown tool "${toolName}".` }],
|
|
64
|
+
isError: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const args = (request.params.arguments as Record<string, unknown>) ?? {};
|
|
70
|
+
const result = await tool.handler(args);
|
|
71
|
+
return {
|
|
72
|
+
content: [{ type: 'text' as const, text: result }],
|
|
73
|
+
};
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: 'text' as const, text: `Error: ${message}` }],
|
|
78
|
+
isError: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Connect using stdio transport
|
|
84
|
+
const transport = new StdioServerTransport();
|
|
85
|
+
await server.connect(transport);
|
|
86
|
+
|
|
87
|
+
console.error(`${SERVER_NAME} v${SERVER_VERSION} running on stdio`);
|
|
88
|
+
console.error(`Registered ${allTools.length} tools`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
main().catch((err) => {
|
|
92
|
+
console.error('Fatal error:', err);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
});
|