@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,189 @@
|
|
|
1
|
+
import { handleFirebaseError, formatSuccess } from '../utils/index.js';
|
|
2
|
+
import type { ToolDefinition } from './types.js';
|
|
3
|
+
|
|
4
|
+
// ============================================================
|
|
5
|
+
// CLOUD FUNCTIONS TOOLS
|
|
6
|
+
// ============================================================
|
|
7
|
+
|
|
8
|
+
export const functionsTools: ToolDefinition[] = [
|
|
9
|
+
// ── functions_list ────────────────────────────────────
|
|
10
|
+
{
|
|
11
|
+
name: 'functions_list',
|
|
12
|
+
description:
|
|
13
|
+
'List deployed Firebase Cloud Functions with their trigger types and status.',
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: 'object' as const,
|
|
16
|
+
properties: {
|
|
17
|
+
region: { type: 'string', description: 'Region filter (default: us-central1)' },
|
|
18
|
+
pageSize: { type: 'number', description: 'Maximum results (default: 100)' },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
handler: async (args: Record<string, unknown>) => {
|
|
22
|
+
try {
|
|
23
|
+
const projectId = process.env['FIREBASE_PROJECT_ID'];
|
|
24
|
+
if (!projectId) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'FIREBASE_PROJECT_ID is required to list functions. ' +
|
|
27
|
+
'Set it as an environment variable.'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const region = (args['region'] as string) || 'us-central1';
|
|
32
|
+
const pageSize = Math.min((args['pageSize'] as number) || 100, 1000);
|
|
33
|
+
|
|
34
|
+
const { GoogleAuth } = await import('google-auth-library');
|
|
35
|
+
const auth = new GoogleAuth({
|
|
36
|
+
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const client = await auth.getClient();
|
|
40
|
+
const url = `https://cloudfunctions.googleapis.com/v2/projects/${projectId}/locations/${region}/functions?pageSize=${pageSize}`;
|
|
41
|
+
|
|
42
|
+
const response = await client.request({ url });
|
|
43
|
+
const data = response.data as { functions?: Array<Record<string, unknown>>; nextPageToken?: string };
|
|
44
|
+
|
|
45
|
+
const functions = (data.functions || []).map((fn) => ({
|
|
46
|
+
name: (fn.name as string)?.split('/').pop(),
|
|
47
|
+
fullName: fn.name,
|
|
48
|
+
state: fn.state,
|
|
49
|
+
updateTime: fn.updateTime,
|
|
50
|
+
buildId: fn.buildId,
|
|
51
|
+
eventTrigger: fn.eventTrigger ? {
|
|
52
|
+
eventType: (fn.eventTrigger as Record<string, unknown>).eventType,
|
|
53
|
+
resource: (fn.eventTrigger as Record<string, unknown>).resource,
|
|
54
|
+
} : undefined,
|
|
55
|
+
httpsTrigger: fn.httpsTrigger ? {
|
|
56
|
+
uri: (fn.httpsTrigger as Record<string, unknown>).uri,
|
|
57
|
+
} : undefined,
|
|
58
|
+
entryPoint: fn.entryPoint,
|
|
59
|
+
runtime: fn.runtime,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
return formatSuccess({
|
|
63
|
+
projectId,
|
|
64
|
+
region,
|
|
65
|
+
count: functions.length,
|
|
66
|
+
functions,
|
|
67
|
+
nextPageToken: data.nextPageToken,
|
|
68
|
+
});
|
|
69
|
+
} catch (err) {
|
|
70
|
+
handleFirebaseError(err, 'functions', 'list');
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// ── functions_trigger ─────────────────────────────────
|
|
76
|
+
{
|
|
77
|
+
name: 'functions_trigger',
|
|
78
|
+
description:
|
|
79
|
+
'Trigger an HTTP Cloud Function by URL. Sends a POST request with optional data payload.',
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: 'object' as const,
|
|
82
|
+
properties: {
|
|
83
|
+
url: { type: 'string', description: 'HTTPS trigger URL of the Cloud Function' },
|
|
84
|
+
data: { description: 'JSON payload to send with the request' },
|
|
85
|
+
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], description: 'HTTP method (default: POST)' },
|
|
86
|
+
},
|
|
87
|
+
required: ['url'],
|
|
88
|
+
},
|
|
89
|
+
handler: async (args: Record<string, unknown>) => {
|
|
90
|
+
try {
|
|
91
|
+
const url = (args['url'] as string).trim();
|
|
92
|
+
const data = args['data'];
|
|
93
|
+
const method = (args['method'] as 'GET' | 'POST' | 'PUT' | 'DELETE') || 'POST';
|
|
94
|
+
|
|
95
|
+
if (!url.startsWith('https://')) {
|
|
96
|
+
throw new Error('URL must start with "https://".');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { GoogleAuth } = await import('google-auth-library');
|
|
100
|
+
const auth = new GoogleAuth();
|
|
101
|
+
const client = await auth.getIdTokenClient(url);
|
|
102
|
+
|
|
103
|
+
const response = await client.request({
|
|
104
|
+
url,
|
|
105
|
+
method,
|
|
106
|
+
data,
|
|
107
|
+
responseType: 'json',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return formatSuccess({
|
|
111
|
+
status: response.status,
|
|
112
|
+
statusText: response.statusText,
|
|
113
|
+
data: response.data,
|
|
114
|
+
});
|
|
115
|
+
} catch (err) {
|
|
116
|
+
handleFirebaseError(err, 'functions', 'trigger');
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
// ── functions_get_logs ────────────────────────────────
|
|
122
|
+
{
|
|
123
|
+
name: 'functions_get_logs',
|
|
124
|
+
description:
|
|
125
|
+
'Get recent logs for a Firebase Cloud Function. Requires FIREBASE_PROJECT_ID.',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: 'object' as const,
|
|
128
|
+
properties: {
|
|
129
|
+
functionName: { type: 'string', description: 'Name of the Cloud Function' },
|
|
130
|
+
region: { type: 'string', description: 'Region (default: us-central1)' },
|
|
131
|
+
pageSize: { type: 'number', description: 'Maximum log entries (default: 50, max 100)' },
|
|
132
|
+
filter: { type: 'string', description: 'Additional log filter expression' },
|
|
133
|
+
},
|
|
134
|
+
required: ['functionName'],
|
|
135
|
+
},
|
|
136
|
+
handler: async (args: Record<string, unknown>) => {
|
|
137
|
+
try {
|
|
138
|
+
const projectId = process.env['FIREBASE_PROJECT_ID'];
|
|
139
|
+
if (!projectId) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
'FIREBASE_PROJECT_ID is required to get function logs.'
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const functionName = (args['functionName'] as string).trim();
|
|
146
|
+
const region = (args['region'] as string) || 'us-central1';
|
|
147
|
+
const pageSize = Math.min((args['pageSize'] as number) || 50, 100);
|
|
148
|
+
const additionalFilter = (args['filter'] as string) || '';
|
|
149
|
+
|
|
150
|
+
const { GoogleAuth } = await import('google-auth-library');
|
|
151
|
+
const auth = new GoogleAuth({
|
|
152
|
+
scopes: ['https://www.googleapis.com/auth/logging.read'],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const client = await auth.getClient();
|
|
156
|
+
const filter = `resource.type="cloud_function" resource.labels.function_name="${functionName}" resource.labels.region="${region}"${additionalFilter ? ` ${additionalFilter}` : ''}`;
|
|
157
|
+
|
|
158
|
+
const url = 'https://logging.googleapis.com/v2/entries:list';
|
|
159
|
+
const response = await client.request({
|
|
160
|
+
url,
|
|
161
|
+
method: 'POST',
|
|
162
|
+
data: {
|
|
163
|
+
resourceNames: [`projects/${projectId}`],
|
|
164
|
+
filter,
|
|
165
|
+
orderBy: 'timestamp desc',
|
|
166
|
+
pageSize,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const data = response.data as { entries?: Array<Record<string, unknown>> };
|
|
171
|
+
const logs = (data.entries || []).map((entry) => ({
|
|
172
|
+
timestamp: entry.timestamp,
|
|
173
|
+
severity: entry.severity,
|
|
174
|
+
textPayload: entry.textPayload,
|
|
175
|
+
jsonPayload: entry.jsonPayload,
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
return formatSuccess({
|
|
179
|
+
functionName,
|
|
180
|
+
region,
|
|
181
|
+
count: logs.length,
|
|
182
|
+
logs,
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
handleFirebaseError(err, 'functions', 'get_logs');
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type { ToolDefinition } from './types.js';
|
|
2
|
+
export { firestoreTools } from './firestore.js';
|
|
3
|
+
export { authTools } from './auth.js';
|
|
4
|
+
export { storageTools } from './storage.js';
|
|
5
|
+
export { realtimeDbTools } from './realtime-db.js';
|
|
6
|
+
export { functionsTools } from './functions.js';
|
|
7
|
+
export { messagingTools } from './messaging.js';
|
|
8
|
+
|
|
9
|
+
import { firestoreTools } from './firestore.js';
|
|
10
|
+
import { authTools } from './auth.js';
|
|
11
|
+
import { storageTools } from './storage.js';
|
|
12
|
+
import { realtimeDbTools } from './realtime-db.js';
|
|
13
|
+
import { functionsTools } from './functions.js';
|
|
14
|
+
import { messagingTools } from './messaging.js';
|
|
15
|
+
import type { ToolDefinition } from './types.js';
|
|
16
|
+
|
|
17
|
+
export const allTools: ToolDefinition[] = [
|
|
18
|
+
...firestoreTools,
|
|
19
|
+
...authTools,
|
|
20
|
+
...storageTools,
|
|
21
|
+
...realtimeDbTools,
|
|
22
|
+
...functionsTools,
|
|
23
|
+
...messagingTools,
|
|
24
|
+
];
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { getMessaging } from '../services/firebase.js';
|
|
2
|
+
import {
|
|
3
|
+
handleFirebaseError,
|
|
4
|
+
formatSuccess,
|
|
5
|
+
} from '../utils/index.js';
|
|
6
|
+
import type { ToolDefinition } from './types.js';
|
|
7
|
+
import * as admin from 'firebase-admin';
|
|
8
|
+
|
|
9
|
+
// ============================================================
|
|
10
|
+
// MESSAGING (FCM) TOOLS
|
|
11
|
+
// ============================================================
|
|
12
|
+
|
|
13
|
+
export const messagingTools: ToolDefinition[] = [
|
|
14
|
+
// ── messaging_send ────────────────────────────────────
|
|
15
|
+
{
|
|
16
|
+
name: 'messaging_send',
|
|
17
|
+
description:
|
|
18
|
+
'Send a push notification to a specific device via FCM token or to a topic.',
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: 'object' as const,
|
|
21
|
+
properties: {
|
|
22
|
+
token: { type: 'string', description: 'FCM device registration token' },
|
|
23
|
+
topic: { type: 'string', description: 'Topic name (alternative to token)' },
|
|
24
|
+
condition: { type: 'string', description: 'Condition expression (alternative to token/topic)' },
|
|
25
|
+
notification: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
title: { type: 'string', description: 'Notification title' },
|
|
29
|
+
body: { type: 'string', description: 'Notification body text' },
|
|
30
|
+
imageUrl: { type: 'string', description: 'Notification image URL' },
|
|
31
|
+
},
|
|
32
|
+
description: 'Notification payload',
|
|
33
|
+
},
|
|
34
|
+
data: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
description: 'Custom data payload (key-value pairs)',
|
|
37
|
+
},
|
|
38
|
+
android: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
priority: { type: 'string', enum: ['high', 'normal'], description: 'Message priority' },
|
|
42
|
+
ttl: { type: 'number', description: 'Time to live in seconds' },
|
|
43
|
+
},
|
|
44
|
+
description: 'Android-specific options',
|
|
45
|
+
},
|
|
46
|
+
apns: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
badge: { type: 'number', description: 'Badge count' },
|
|
50
|
+
sound: { type: 'string', description: 'Sound file name' },
|
|
51
|
+
},
|
|
52
|
+
description: 'APNs-specific options',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
handler: async (args: Record<string, unknown>) => {
|
|
57
|
+
try {
|
|
58
|
+
const messaging = getMessaging();
|
|
59
|
+
|
|
60
|
+
const hasToken = !!args['token'];
|
|
61
|
+
const hasTopic = !!args['topic'];
|
|
62
|
+
const hasCondition = !!args['condition'];
|
|
63
|
+
|
|
64
|
+
if ([hasToken, hasTopic, hasCondition].filter(Boolean).length > 1) {
|
|
65
|
+
throw new Error('Provide exactly one of: token, topic, or condition.');
|
|
66
|
+
}
|
|
67
|
+
if (!hasToken && !hasTopic && !hasCondition) {
|
|
68
|
+
throw new Error('Must provide one of: token, topic, or condition.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Build message using the appropriate type
|
|
72
|
+
// We need to construct the message based on the target type
|
|
73
|
+
let message: admin.messaging.Message;
|
|
74
|
+
|
|
75
|
+
if (hasToken) {
|
|
76
|
+
message = { token: (args['token'] as string).trim() };
|
|
77
|
+
} else if (hasTopic) {
|
|
78
|
+
message = { topic: (args['topic'] as string).trim() };
|
|
79
|
+
} else {
|
|
80
|
+
message = { condition: (args['condition'] as string).trim() };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Notification
|
|
84
|
+
if (args['notification']) {
|
|
85
|
+
const notif = args['notification'] as { title?: string; body?: string; imageUrl?: string };
|
|
86
|
+
message.notification = {
|
|
87
|
+
title: notif.title || '',
|
|
88
|
+
body: notif.body || '',
|
|
89
|
+
...(notif.imageUrl && { imageUrl: notif.imageUrl }),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Data payload (values must be strings for FCM)
|
|
94
|
+
if (args['data']) {
|
|
95
|
+
const rawData = args['data'] as Record<string, unknown>;
|
|
96
|
+
const stringData: Record<string, string> = {};
|
|
97
|
+
for (const [key, value] of Object.entries(rawData)) {
|
|
98
|
+
stringData[key] = String(value);
|
|
99
|
+
}
|
|
100
|
+
message.data = stringData;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Android config
|
|
104
|
+
if (args['android']) {
|
|
105
|
+
const android = args['android'] as { priority?: string; ttl?: number };
|
|
106
|
+
message.android = {
|
|
107
|
+
priority: (android.priority as 'high' | 'normal') || 'high',
|
|
108
|
+
...(android.ttl !== undefined && { ttl: android.ttl * 1000 }),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// APNs config
|
|
113
|
+
if (args['apns']) {
|
|
114
|
+
const apns = args['apns'] as { badge?: number; sound?: string };
|
|
115
|
+
message.apns = {
|
|
116
|
+
payload: {
|
|
117
|
+
aps: {
|
|
118
|
+
...(apns.badge !== undefined && { badge: apns.badge }),
|
|
119
|
+
...(apns.sound && { sound: apns.sound }),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const messageId = await messaging.send(message);
|
|
126
|
+
|
|
127
|
+
return formatSuccess({
|
|
128
|
+
messageId,
|
|
129
|
+
target: hasToken ? `token:${(args['token'] as string).substring(0, 10)}...` :
|
|
130
|
+
hasTopic ? `topic:${args['topic']}` :
|
|
131
|
+
`condition:${args['condition']}`,
|
|
132
|
+
message: 'Notification sent successfully.',
|
|
133
|
+
});
|
|
134
|
+
} catch (err) {
|
|
135
|
+
handleFirebaseError(err, 'messaging', 'send');
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// ── messaging_send_multicast ──────────────────────────
|
|
141
|
+
{
|
|
142
|
+
name: 'messaging_send_multicast',
|
|
143
|
+
description:
|
|
144
|
+
'Send a push notification to multiple devices (up to 500 tokens) in a single request.',
|
|
145
|
+
inputSchema: {
|
|
146
|
+
type: 'object' as const,
|
|
147
|
+
properties: {
|
|
148
|
+
tokens: {
|
|
149
|
+
type: 'array',
|
|
150
|
+
items: { type: 'string' },
|
|
151
|
+
description: 'Array of FCM device tokens (max 500)',
|
|
152
|
+
},
|
|
153
|
+
notification: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
title: { type: 'string' },
|
|
157
|
+
body: { type: 'string' },
|
|
158
|
+
imageUrl: { type: 'string' },
|
|
159
|
+
},
|
|
160
|
+
description: 'Notification payload',
|
|
161
|
+
},
|
|
162
|
+
data: { type: 'object', description: 'Custom data payload' },
|
|
163
|
+
},
|
|
164
|
+
required: ['tokens'],
|
|
165
|
+
},
|
|
166
|
+
handler: async (args: Record<string, unknown>) => {
|
|
167
|
+
try {
|
|
168
|
+
const tokens = args['tokens'] as string[];
|
|
169
|
+
if (!tokens || tokens.length === 0) {
|
|
170
|
+
throw new Error('Tokens array cannot be empty.');
|
|
171
|
+
}
|
|
172
|
+
if (tokens.length > 500) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Too many tokens: ${tokens.length}. Maximum is 500 per multicast. ` +
|
|
175
|
+
'Split into multiple batches.'
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const messaging = getMessaging();
|
|
180
|
+
const message: admin.messaging.MulticastMessage = {
|
|
181
|
+
tokens: tokens.map((t) => t.trim()),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
if (args['notification']) {
|
|
185
|
+
const notif = args['notification'] as { title?: string; body?: string; imageUrl?: string };
|
|
186
|
+
message.notification = {
|
|
187
|
+
title: notif.title || '',
|
|
188
|
+
body: notif.body || '',
|
|
189
|
+
...(notif.imageUrl && { imageUrl: notif.imageUrl }),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (args['data']) {
|
|
194
|
+
const rawData = args['data'] as Record<string, unknown>;
|
|
195
|
+
const stringData: Record<string, string> = {};
|
|
196
|
+
for (const [key, value] of Object.entries(rawData)) {
|
|
197
|
+
stringData[key] = String(value);
|
|
198
|
+
}
|
|
199
|
+
message.data = stringData;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const batchResponse = await messaging.sendEachForMulticast(message);
|
|
203
|
+
|
|
204
|
+
const failedTokens: string[] = [];
|
|
205
|
+
if (batchResponse.failureCount > 0) {
|
|
206
|
+
batchResponse.responses.forEach((resp, idx) => {
|
|
207
|
+
if (!resp.success) {
|
|
208
|
+
failedTokens.push(tokens[idx]!);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return formatSuccess({
|
|
214
|
+
successCount: batchResponse.successCount,
|
|
215
|
+
failureCount: batchResponse.failureCount,
|
|
216
|
+
totalSent: tokens.length,
|
|
217
|
+
...(failedTokens.length > 0 && {
|
|
218
|
+
failedTokens: failedTokens.slice(0, 50),
|
|
219
|
+
message: `${batchResponse.successCount} succeeded, ${batchResponse.failureCount} failed.`,
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
} catch (err) {
|
|
223
|
+
handleFirebaseError(err, 'messaging', 'send_multicast');
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
// ── messaging_subscribe_topic ─────────────────────────
|
|
229
|
+
{
|
|
230
|
+
name: 'messaging_subscribe_topic',
|
|
231
|
+
description: 'Subscribe device tokens to an FCM topic for topic-based messaging.',
|
|
232
|
+
inputSchema: {
|
|
233
|
+
type: 'object' as const,
|
|
234
|
+
properties: {
|
|
235
|
+
tokens: {
|
|
236
|
+
type: 'array',
|
|
237
|
+
items: { type: 'string' },
|
|
238
|
+
description: 'Array of FCM tokens to subscribe (max 1000)',
|
|
239
|
+
},
|
|
240
|
+
topic: { type: 'string', description: 'Topic name to subscribe to' },
|
|
241
|
+
},
|
|
242
|
+
required: ['tokens', 'topic'],
|
|
243
|
+
},
|
|
244
|
+
handler: async (args: Record<string, unknown>) => {
|
|
245
|
+
try {
|
|
246
|
+
const tokens = args['tokens'] as string[];
|
|
247
|
+
const topic = (args['topic'] as string).trim();
|
|
248
|
+
|
|
249
|
+
if (!tokens || tokens.length === 0) {
|
|
250
|
+
throw new Error('Tokens array cannot be empty.');
|
|
251
|
+
}
|
|
252
|
+
if (tokens.length > 1000) {
|
|
253
|
+
throw new Error('Maximum 1000 tokens per subscribe request.');
|
|
254
|
+
}
|
|
255
|
+
if (!topic) {
|
|
256
|
+
throw new Error('Topic name cannot be empty.');
|
|
257
|
+
}
|
|
258
|
+
if (!/^[a-zA-Z0-9-_.~%]+$/.test(topic)) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`Invalid topic name: "${topic}". ` +
|
|
261
|
+
'Topic names must match: [a-zA-Z0-9-_.~%]+'
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const messaging = getMessaging();
|
|
266
|
+
const response = await messaging.subscribeToTopic(tokens, topic);
|
|
267
|
+
|
|
268
|
+
return formatSuccess({
|
|
269
|
+
successCount: response.successCount,
|
|
270
|
+
failureCount: response.failureCount,
|
|
271
|
+
topic,
|
|
272
|
+
tokensRequested: tokens.length,
|
|
273
|
+
message: `${response.successCount} tokens subscribed to "${topic}".`,
|
|
274
|
+
});
|
|
275
|
+
} catch (err) {
|
|
276
|
+
handleFirebaseError(err, 'messaging', 'subscribe_topic');
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
// ── messaging_unsubscribe_topic ───────────────────────
|
|
282
|
+
{
|
|
283
|
+
name: 'messaging_unsubscribe_topic',
|
|
284
|
+
description: 'Unsubscribe device tokens from an FCM topic.',
|
|
285
|
+
inputSchema: {
|
|
286
|
+
type: 'object' as const,
|
|
287
|
+
properties: {
|
|
288
|
+
tokens: {
|
|
289
|
+
type: 'array',
|
|
290
|
+
items: { type: 'string' },
|
|
291
|
+
description: 'Array of FCM tokens to unsubscribe (max 1000)',
|
|
292
|
+
},
|
|
293
|
+
topic: { type: 'string', description: 'Topic name to unsubscribe from' },
|
|
294
|
+
},
|
|
295
|
+
required: ['tokens', 'topic'],
|
|
296
|
+
},
|
|
297
|
+
handler: async (args: Record<string, unknown>) => {
|
|
298
|
+
try {
|
|
299
|
+
const tokens = args['tokens'] as string[];
|
|
300
|
+
const topic = (args['topic'] as string).trim();
|
|
301
|
+
|
|
302
|
+
if (!tokens || tokens.length === 0) {
|
|
303
|
+
throw new Error('Tokens array cannot be empty.');
|
|
304
|
+
}
|
|
305
|
+
if (!topic) {
|
|
306
|
+
throw new Error('Topic name cannot be empty.');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const messaging = getMessaging();
|
|
310
|
+
const response = await messaging.unsubscribeFromTopic(tokens, topic);
|
|
311
|
+
|
|
312
|
+
return formatSuccess({
|
|
313
|
+
successCount: response.successCount,
|
|
314
|
+
failureCount: response.failureCount,
|
|
315
|
+
topic,
|
|
316
|
+
tokensRequested: tokens.length,
|
|
317
|
+
message: `${response.successCount} tokens unsubscribed from "${topic}".`,
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
handleFirebaseError(err, 'messaging', 'unsubscribe_topic');
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
];
|