@exposify/firebase 0.0.0-stage → 0.0.2

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/dist/index.cjs ADDED
@@ -0,0 +1,874 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ aiReady: () => aiReady,
24
+ callableUrl: () => callableUrl,
25
+ completeMcpLogin: () => import_core3.completeMcpLogin,
26
+ createFirebaseMcpHandler: () => createFirebaseMcpHandler,
27
+ createHostedAuthResolver: () => import_core3.createHostedAuthResolver,
28
+ firebaseToolsPhrase: () => firebaseToolsPhrase,
29
+ functionPublicUrl: () => functionPublicUrl,
30
+ hostedBroker: () => import_core3.hostedBroker,
31
+ parseCollectionKey: () => parseCollectionKey,
32
+ toNodeHandler: () => toNodeHandler
33
+ });
34
+ module.exports = __toCommonJS(index_exports);
35
+ var import_core2 = require("@exposify/core");
36
+ var import_node = require("@exposify/core/node");
37
+
38
+ // src/path.ts
39
+ var PARAM = /^\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
40
+ function parseCollectionKey(key) {
41
+ const segments = key.replace(/^\/+|\/+$/g, "").split("/");
42
+ if (segments.some((s) => s === "")) throw new Error("empty path segment");
43
+ if (segments.length % 2 === 0) {
44
+ throw new Error("names a document, not a collection (a collection path has an odd number of segments)");
45
+ }
46
+ const params = [];
47
+ segments.forEach((seg, i) => {
48
+ const param = seg.match(PARAM);
49
+ if (i % 2 === 0) {
50
+ if (seg.includes("{") || seg.includes("}")) throw new Error(`collection id "${seg}" can't be a placeholder`);
51
+ checkSegment(seg);
52
+ } else if (param) {
53
+ if (params.includes(param[1])) throw new Error(`placeholder {${param[1]}} appears twice`);
54
+ if (param[1] === "id") throw new Error("{id} is reserved for the document id argument; rename the placeholder");
55
+ params.push(param[1]);
56
+ } else {
57
+ if (seg.includes("{") || seg.includes("}")) throw new Error(`"${seg}" is not a valid {placeholder}`);
58
+ checkSegment(seg);
59
+ }
60
+ });
61
+ const suffix = sanitize(segments.filter((_, i) => i % 2 === 0).join("_"));
62
+ if (!suffix) throw new Error("collection ids produce an empty tool name");
63
+ return { segments, params, suffix };
64
+ }
65
+ function checkSegment(value) {
66
+ if (value.length === 0) throw new Error("must not be empty");
67
+ if (value.includes("/")) throw new Error("must not contain '/'");
68
+ if (value === "." || value === "..") throw new Error("must not be '.' or '..'");
69
+ if (/^__.*__$/.test(value)) throw new Error("must not match __.*__ (reserved by Firestore)");
70
+ if (new TextEncoder().encode(value).length > 1500) throw new Error("must be at most 1500 bytes");
71
+ }
72
+ function collectionPath(segments, args) {
73
+ return segments.map((seg) => {
74
+ const param = seg.match(PARAM);
75
+ if (!param) return encodeURIComponent(seg);
76
+ const value = args[param[1]];
77
+ if (typeof value !== "string") throw new Error(`${param[1]} is required`);
78
+ try {
79
+ checkSegment(value);
80
+ } catch (err) {
81
+ throw new Error(`${param[1]} ${err.message}`);
82
+ }
83
+ return encodeURIComponent(value);
84
+ }).join("/");
85
+ }
86
+ function documentPath(segments, args, id) {
87
+ try {
88
+ checkSegment(id);
89
+ } catch (err) {
90
+ throw new Error(`id ${err.message}`);
91
+ }
92
+ return `${collectionPath(segments, args)}/${encodeURIComponent(id)}`;
93
+ }
94
+ function sanitize(name) {
95
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
96
+ }
97
+ function toolName(prefix, suffix, taken) {
98
+ const base = `${prefix}_${sanitize(suffix)}`;
99
+ let candidate = base;
100
+ let i = 2;
101
+ while (taken.has(candidate)) candidate = `${base}_${i++}`;
102
+ taken.add(candidate);
103
+ return candidate;
104
+ }
105
+
106
+ // src/config.ts
107
+ var LOG_PREFIX = "[exposify-firebase]";
108
+ var WRITE_OPS = ["create", "update", "delete"];
109
+ function parseWrites(writes = []) {
110
+ const grants = /* @__PURE__ */ new Map();
111
+ for (const entry of writes) {
112
+ const dot = entry.lastIndexOf(".");
113
+ const key = dot > 0 ? entry.slice(0, dot) : "";
114
+ const op = dot > 0 ? entry.slice(dot + 1) : "";
115
+ if (!key || !op) {
116
+ warnOnce(`ignoring malformed writes entry "${entry}" (expected "<collection>.create|update|delete|*")`);
117
+ continue;
118
+ }
119
+ const ops = op === "*" ? WRITE_OPS : WRITE_OPS.includes(op) ? [op] : null;
120
+ if (!ops) {
121
+ warnOnce(`ignoring writes entry "${entry}": "${op}" is not create, update, delete, or *`);
122
+ continue;
123
+ }
124
+ const set = grants.get(normalizeKey(key)) ?? /* @__PURE__ */ new Set();
125
+ for (const o of ops) set.add(o);
126
+ grants.set(normalizeKey(key), set);
127
+ }
128
+ return grants;
129
+ }
130
+ function normalizeKey(key) {
131
+ return key.replace(/^\/+|\/+$/g, "");
132
+ }
133
+ function resolveFirebaseConfig(options = {}, hostedBroker2 = false) {
134
+ const entries = Array.isArray(options.collections) ? options.collections.map((name) => [name, {}]) : Object.entries(options.collections ?? {});
135
+ const grants = parseWrites(options.writes);
136
+ const collections = [];
137
+ for (const [rawKey, config] of entries) {
138
+ const key = normalizeKey(rawKey);
139
+ let parsed;
140
+ try {
141
+ parsed = parseCollectionKey(key);
142
+ } catch (err) {
143
+ warnOnce(`ignoring collection "${rawKey}": ${err.message}`);
144
+ continue;
145
+ }
146
+ if (config.owner && parsed.params.includes(config.owner)) {
147
+ warnOnce(`collection "${rawKey}": owner "${config.owner}" is also a path placeholder; it is treated as a document field`);
148
+ }
149
+ collections.push({ key, ...parsed, config: config ?? {}, writes: grants.get(key) ?? /* @__PURE__ */ new Set() });
150
+ }
151
+ const known = new Set(collections.map((c) => c.key));
152
+ for (const key of grants.keys()) {
153
+ if (!known.has(key)) warnOnce(`writes names "${key}", which is not in collections; add it there to expose it`);
154
+ }
155
+ return {
156
+ collections,
157
+ functions: options.functions ?? {},
158
+ docs: options.docs ?? ["docs/**/*.md"],
159
+ docsAuth: options.docsAuth ?? (hostedBroker2 ? "required" : "public"),
160
+ maxDocs: options.maxDocs ?? 100,
161
+ databaseId: options.databaseId ?? "(default)",
162
+ functionsRegion: options.functionsRegion ?? "us-central1",
163
+ rateLimit: options.rateLimit,
164
+ rateWindowMs: options.rateWindowMs
165
+ };
166
+ }
167
+ function projectIdFromEnv(env) {
168
+ if (env.GCLOUD_PROJECT) return env.GCLOUD_PROJECT;
169
+ if (env.GOOGLE_CLOUD_PROJECT) return env.GOOGLE_CLOUD_PROJECT;
170
+ if (env.FIREBASE_CONFIG) {
171
+ try {
172
+ const parsed = JSON.parse(env.FIREBASE_CONFIG);
173
+ if (typeof parsed.projectId === "string") return parsed.projectId;
174
+ } catch {
175
+ }
176
+ }
177
+ return void 0;
178
+ }
179
+ var warned = /* @__PURE__ */ new Set();
180
+ function warnOnce(message) {
181
+ if (warned.has(message)) return;
182
+ warned.add(message);
183
+ try {
184
+ console.warn(`${LOG_PREFIX} ${message}`);
185
+ } catch {
186
+ }
187
+ }
188
+
189
+ // src/values.ts
190
+ function encodeValue(value, schema) {
191
+ if (value === null || value === void 0) return { nullValue: null };
192
+ if (typeof value === "boolean") return { booleanValue: value };
193
+ if (typeof value === "number") {
194
+ if (Number.isInteger(value) && schema?.type !== "number") return { integerValue: String(value) };
195
+ return { doubleValue: value };
196
+ }
197
+ if (typeof value === "string") {
198
+ if (schema?.format === "date-time") {
199
+ const ms = Date.parse(value);
200
+ if (Number.isNaN(ms)) throw new Error(`"${value}" is not a date-time`);
201
+ return { timestampValue: new Date(ms).toISOString() };
202
+ }
203
+ return { stringValue: value };
204
+ }
205
+ if (Array.isArray(value)) {
206
+ return { arrayValue: { values: value.map((v) => encodeValue(v, schema?.items)) } };
207
+ }
208
+ if (typeof value === "object") {
209
+ return { mapValue: { fields: encodeFields(value, schema?.properties) } };
210
+ }
211
+ throw new Error(`unsupported value of type ${typeof value}`);
212
+ }
213
+ function encodeFields(data, schema) {
214
+ const fields = {};
215
+ for (const [key, v] of Object.entries(data)) {
216
+ if (v === void 0) continue;
217
+ fields[key] = encodeValue(v, schema?.[key]);
218
+ }
219
+ return fields;
220
+ }
221
+ function decodeValue(value) {
222
+ if ("nullValue" in value) return null;
223
+ if ("booleanValue" in value) return value.booleanValue;
224
+ if ("integerValue" in value) {
225
+ const n = Number(value.integerValue);
226
+ return Number.isSafeInteger(n) ? n : value.integerValue;
227
+ }
228
+ if ("doubleValue" in value) return Number(value.doubleValue);
229
+ if ("timestampValue" in value) return value.timestampValue;
230
+ if ("stringValue" in value) return value.stringValue;
231
+ if ("bytesValue" in value) return value.bytesValue;
232
+ if ("referenceValue" in value) return relativeDocPath(value.referenceValue);
233
+ if ("geoPointValue" in value) {
234
+ return { latitude: value.geoPointValue.latitude ?? 0, longitude: value.geoPointValue.longitude ?? 0 };
235
+ }
236
+ if ("arrayValue" in value) return (value.arrayValue.values ?? []).map(decodeValue);
237
+ if ("mapValue" in value) return decodeFields(value.mapValue.fields);
238
+ return null;
239
+ }
240
+ function decodeFields(fields) {
241
+ const out = {};
242
+ for (const [key, v] of Object.entries(fields ?? {})) out[key] = decodeValue(v);
243
+ return out;
244
+ }
245
+ function relativeDocPath(name) {
246
+ const marker = "/documents/";
247
+ const i = name.indexOf(marker);
248
+ return i === -1 ? name : name.slice(i + marker.length);
249
+ }
250
+ function decodeDocument(doc) {
251
+ const path = relativeDocPath(doc.name);
252
+ return {
253
+ id: path.slice(path.lastIndexOf("/") + 1),
254
+ path,
255
+ data: decodeFields(doc.fields),
256
+ createTime: doc.createTime,
257
+ updateTime: doc.updateTime
258
+ };
259
+ }
260
+
261
+ // src/firestore-client.ts
262
+ function documentsBaseUrl(projectId, databaseId, emulatorHost) {
263
+ const origin = emulatorHost ? `http://${emulatorHost.replace(/^https?:\/\//, "")}` : "https://firestore.googleapis.com";
264
+ return `${origin}/v1/projects/${encodeURIComponent(projectId)}/databases/${encodeURIComponent(databaseId)}/documents`;
265
+ }
266
+ function createFirestoreClient(baseUrl, authorization, fetchImpl) {
267
+ const headers = { "content-type": "application/json" };
268
+ if (authorization) headers.authorization = authorization;
269
+ const call = async (url, init, decode) => {
270
+ const res = await fetchImpl(url, { ...init, headers });
271
+ const text = await res.text();
272
+ if (!res.ok) return { status: res.status, bodyText: text };
273
+ return { status: res.status, bodyText: JSON.stringify(decode(text ? JSON.parse(text) : {})) };
274
+ };
275
+ return {
276
+ runQuery: (parent, structuredQuery) => call(
277
+ `${baseUrl}${parent ? `/${parent}` : ""}:runQuery`,
278
+ { method: "POST", body: JSON.stringify({ structuredQuery }) },
279
+ (body) => body.filter((r) => r.document).map((r) => decodeDocument(r.document))
280
+ ),
281
+ get: (docPath) => call(`${baseUrl}/${docPath}`, { method: "GET" }, (body) => decodeDocument(body)),
282
+ create: (collectionPath2, id, fields) => call(
283
+ `${baseUrl}/${collectionPath2}${id ? `?documentId=${encodeURIComponent(id)}` : ""}`,
284
+ { method: "POST", body: JSON.stringify({ fields }) },
285
+ (body) => decodeDocument(body)
286
+ ),
287
+ update: (docPath, fields) => {
288
+ const params = new URLSearchParams();
289
+ for (const key of Object.keys(fields)) params.append("updateMask.fieldPaths", quoteSegment(key));
290
+ params.set("currentDocument.exists", "true");
291
+ return call(
292
+ `${baseUrl}/${docPath}?${params}`,
293
+ { method: "PATCH", body: JSON.stringify({ fields }) },
294
+ (body) => decodeDocument(body)
295
+ );
296
+ },
297
+ delete: (docPath) => call(`${baseUrl}/${docPath}?currentDocument.exists=true`, { method: "DELETE" }, () => ({ deleted: decodeURIComponent(docPath) }))
298
+ };
299
+ }
300
+ function quoteSegment(name) {
301
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `\`${name.replace(/\\/g, "\\\\").replace(/`/g, "\\`")}\``;
302
+ }
303
+
304
+ // src/server.ts
305
+ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
306
+ var import_zod2 = require("zod");
307
+ var import_core = require("@exposify/core");
308
+
309
+ // src/query.ts
310
+ var WHERE_OPS = [
311
+ "==",
312
+ "!=",
313
+ "<",
314
+ "<=",
315
+ ">",
316
+ ">=",
317
+ "array-contains",
318
+ "in",
319
+ "not-in",
320
+ "array-contains-any"
321
+ ];
322
+ var REST_OPS = {
323
+ "==": "EQUAL",
324
+ "!=": "NOT_EQUAL",
325
+ "<": "LESS_THAN",
326
+ "<=": "LESS_THAN_OR_EQUAL",
327
+ ">": "GREATER_THAN",
328
+ ">=": "GREATER_THAN_OR_EQUAL",
329
+ "array-contains": "ARRAY_CONTAINS",
330
+ in: "IN",
331
+ "not-in": "NOT_IN",
332
+ "array-contains-any": "ARRAY_CONTAINS_ANY"
333
+ };
334
+ var LIST_OPS = /* @__PURE__ */ new Set(["in", "not-in", "array-contains-any"]);
335
+ function buildStructuredQuery(collectionId, args, maxDocs, fields) {
336
+ const filters = (args.where ?? []).map((w) => {
337
+ const schema = fields?.[w.field];
338
+ if (LIST_OPS.has(w.op) && !Array.isArray(w.value)) {
339
+ throw new Error(`"${w.op}" needs an array value`);
340
+ }
341
+ const valueSchema = w.op === "array-contains" ? schema?.items : LIST_OPS.has(w.op) && schema?.type !== "array" ? { type: "array", items: schema } : schema;
342
+ return {
343
+ fieldFilter: {
344
+ field: { fieldPath: fieldPath(w.field) },
345
+ op: REST_OPS[w.op],
346
+ value: encodeValue(w.value, valueSchema)
347
+ }
348
+ };
349
+ });
350
+ const query = {
351
+ from: [{ collectionId }],
352
+ limit: Math.min(args.limit ?? maxDocs, maxDocs)
353
+ };
354
+ if (filters.length === 1) query.where = filters[0];
355
+ if (filters.length > 1) query.where = { compositeFilter: { op: "AND", filters } };
356
+ if (args.orderBy?.length) {
357
+ query.orderBy = args.orderBy.map((o) => ({
358
+ field: { fieldPath: fieldPath(o.field) },
359
+ direction: o.direction === "desc" ? "DESCENDING" : "ASCENDING"
360
+ }));
361
+ }
362
+ if (args.select?.length) {
363
+ query.select = { fields: args.select.map((f) => ({ fieldPath: fieldPath(f) })) };
364
+ }
365
+ return query;
366
+ }
367
+ function fieldPath(path) {
368
+ return path.split(".").map((seg) => {
369
+ if (seg === "") throw new Error(`invalid field path "${path}"`);
370
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(seg) ? seg : `\`${seg.replace(/\\/g, "\\\\").replace(/`/g, "\\`")}\``;
371
+ }).join(".");
372
+ }
373
+
374
+ // src/jsonschema-to-zod.ts
375
+ var import_zod = require("zod");
376
+ function jsonSchemaToZodShape(schema) {
377
+ if (!schema?.properties) return {};
378
+ const required = new Set(schema.required ?? []);
379
+ const shape = {};
380
+ for (const [name, prop] of Object.entries(schema.properties)) {
381
+ let field = propertyToZod(prop);
382
+ if (!required.has(name)) field = field.optional();
383
+ shape[name] = field;
384
+ }
385
+ return shape;
386
+ }
387
+ function propertyToZod(prop) {
388
+ let base;
389
+ switch (prop.type) {
390
+ case "string":
391
+ base = prop.enum ? import_zod.z.enum(prop.enum) : import_zod.z.string();
392
+ break;
393
+ case "number":
394
+ base = import_zod.z.number();
395
+ break;
396
+ case "integer":
397
+ base = import_zod.z.number().int();
398
+ break;
399
+ case "boolean":
400
+ base = import_zod.z.boolean();
401
+ break;
402
+ case "array":
403
+ base = import_zod.z.array(prop.items ? propertyToZod(prop.items) : import_zod.z.unknown());
404
+ break;
405
+ case "object":
406
+ base = prop.properties ? import_zod.z.object(jsonSchemaToZodShape({ properties: prop.properties, required: prop.required })) : import_zod.z.record(import_zod.z.unknown());
407
+ break;
408
+ default:
409
+ base = import_zod.z.unknown();
410
+ }
411
+ return prop.description ? base.describe(prop.description) : base;
412
+ }
413
+
414
+ // src/server.ts
415
+ function buildFirebaseMcpServer(deps) {
416
+ const { cfg, client, kb, incoming } = deps;
417
+ const server = new import_mcp.McpServer({ name: deps.appName, version: deps.appVersion });
418
+ const taken = /* @__PURE__ */ new Set(["search_docs"]);
419
+ for (const coll of cfg.collections) {
420
+ registerListTool(server, coll, deps, taken);
421
+ registerGetTool(server, coll, client, taken);
422
+ if (coll.writes.has("create")) registerCreateTool(server, coll, deps, taken);
423
+ if (coll.writes.has("update")) registerUpdateTool(server, coll, deps, taken);
424
+ if (coll.writes.has("delete")) registerDeleteTool(server, coll, client, taken);
425
+ }
426
+ for (const [name, config] of Object.entries(cfg.functions)) {
427
+ registerCallableTool(server, name, config, deps.invokeCallable, taken);
428
+ }
429
+ registerDocsTool(server, kb, cfg, incoming);
430
+ return server;
431
+ }
432
+ function paramShape(coll) {
433
+ const shape = {};
434
+ for (const p of coll.params) shape[p] = import_zod2.z.string().describe(`Path segment {${p}} of ${coll.key}`);
435
+ return shape;
436
+ }
437
+ function fieldNames(coll) {
438
+ return Object.keys(coll.config.fields ?? {});
439
+ }
440
+ function fieldArg(coll) {
441
+ const names = fieldNames(coll);
442
+ if (names.length === 0) return import_zod2.z.string().describe("Field name; dots address nested fields (address.city)");
443
+ return import_zod2.z.enum(names);
444
+ }
445
+ function describeCollection(coll) {
446
+ return coll.config.description ? ` ${coll.config.description.trim()}` : "";
447
+ }
448
+ function registerListTool(server, coll, deps, taken) {
449
+ const { cfg, client, uid } = deps;
450
+ const owner = coll.config.owner;
451
+ const name = toolName("list", coll.suffix, taken);
452
+ const ownerNote = owner ? ` Only returns documents whose ${owner} is the signed-in user.` : "";
453
+ server.registerTool(
454
+ name,
455
+ {
456
+ description: `List documents in ${coll.key}.${describeCollection(coll)}${ownerNote}`,
457
+ inputSchema: {
458
+ ...paramShape(coll),
459
+ where: import_zod2.z.array(
460
+ import_zod2.z.object({
461
+ field: fieldArg(coll),
462
+ op: import_zod2.z.enum(WHERE_OPS),
463
+ value: import_zod2.z.union([import_zod2.z.string(), import_zod2.z.number(), import_zod2.z.boolean(), import_zod2.z.null(), import_zod2.z.array(import_zod2.z.union([import_zod2.z.string(), import_zod2.z.number(), import_zod2.z.boolean()]))])
464
+ })
465
+ ).optional().describe("Filters, ANDed together. Firestore may ask for a composite index; the error links to create it."),
466
+ orderBy: import_zod2.z.array(import_zod2.z.object({ field: fieldArg(coll), direction: import_zod2.z.enum(["asc", "desc"]).optional() })).optional(),
467
+ limit: import_zod2.z.number().int().min(1).max(cfg.maxDocs).optional().describe(`Max documents (capped at ${cfg.maxDocs})`),
468
+ select: import_zod2.z.array(fieldArg(coll)).optional().describe("Fields to return; default all")
469
+ },
470
+ annotations: { readOnlyHint: true }
471
+ },
472
+ async (args) => {
473
+ let parent;
474
+ let query;
475
+ try {
476
+ if (owner && !uid) return errorResult(`${coll.key} is scoped to the signed-in user; connect with a Firebase ID token.`);
477
+ const where = [...args.where ?? []];
478
+ if (owner) where.push({ field: owner, op: "==", value: uid });
479
+ const path = collectionPath(coll.segments, args);
480
+ parent = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
481
+ query = buildStructuredQuery(coll.segments[coll.segments.length - 1], { ...args, where }, cfg.maxDocs, coll.config.fields);
482
+ } catch (err) {
483
+ return errorResult(err.message);
484
+ }
485
+ const result = await toResult(() => client.runQuery(parent, query));
486
+ if (result.isError && result.content[0].text.startsWith("HTTP 403") && !owner) {
487
+ result.content[0].text += "\nIf this collection's rules only allow reading your own documents, filter on the field that holds your uid.";
488
+ }
489
+ return result;
490
+ }
491
+ );
492
+ }
493
+ function registerGetTool(server, coll, client, taken) {
494
+ const name = toolName("get", coll.suffix, taken);
495
+ server.registerTool(
496
+ name,
497
+ {
498
+ description: `Get one document from ${coll.key} by id.${describeCollection(coll)}`,
499
+ inputSchema: { ...paramShape(coll), id: import_zod2.z.string().describe("Document id") },
500
+ annotations: { readOnlyHint: true }
501
+ },
502
+ async (args) => {
503
+ let path;
504
+ try {
505
+ path = documentPath(coll.segments, args, args.id);
506
+ } catch (err) {
507
+ return errorResult(err.message);
508
+ }
509
+ return toResult(() => client.get(path));
510
+ }
511
+ );
512
+ }
513
+ function dataShape(coll, partial) {
514
+ const fields = coll.config.fields;
515
+ if (!fields || Object.keys(fields).length === 0) return import_zod2.z.record(import_zod2.z.unknown()).describe("Document fields");
516
+ const required = new Set(partial ? [] : coll.config.required ?? []);
517
+ const shape = {};
518
+ for (const [field, prop] of Object.entries(fields)) {
519
+ if (field === coll.config.owner) continue;
520
+ const base = propertyToZod(prop);
521
+ shape[field] = required.has(field) ? base : base.optional();
522
+ }
523
+ return import_zod2.z.object(shape);
524
+ }
525
+ function registerCreateTool(server, coll, deps, taken) {
526
+ const { client, uid } = deps;
527
+ const owner = coll.config.owner;
528
+ const name = toolName("create", coll.suffix, taken);
529
+ server.registerTool(
530
+ name,
531
+ {
532
+ description: `Create a document in ${coll.key}.${describeCollection(coll)}${owner ? ` ${owner} is set to the signed-in user.` : ""}`,
533
+ inputSchema: {
534
+ ...paramShape(coll),
535
+ id: import_zod2.z.string().optional().describe("Document id; omit to let Firestore generate one"),
536
+ data: dataShape(coll, false)
537
+ },
538
+ annotations: { readOnlyHint: false, destructiveHint: false }
539
+ },
540
+ async (args) => {
541
+ let path;
542
+ let fields;
543
+ try {
544
+ if (owner && !uid) return errorResult(`${coll.key} is scoped to the signed-in user; connect with a Firebase ID token.`);
545
+ path = collectionPath(coll.segments, args);
546
+ if (args.id !== void 0) documentPath(coll.segments, args, args.id);
547
+ fields = encodeFields(owner ? { ...args.data, [owner]: uid } : args.data, coll.config.fields);
548
+ } catch (err) {
549
+ return errorResult(err.message);
550
+ }
551
+ return toResult(() => client.create(path, args.id, fields));
552
+ }
553
+ );
554
+ }
555
+ function registerUpdateTool(server, coll, deps, taken) {
556
+ const { client, uid } = deps;
557
+ const owner = coll.config.owner;
558
+ const name = toolName("update", coll.suffix, taken);
559
+ server.registerTool(
560
+ name,
561
+ {
562
+ description: `Update fields of one existing document in ${coll.key}. Only the given fields change.${describeCollection(coll)}`,
563
+ inputSchema: { ...paramShape(coll), id: import_zod2.z.string().describe("Document id"), data: dataShape(coll, true) },
564
+ annotations: { readOnlyHint: false, destructiveHint: true }
565
+ },
566
+ async (args) => {
567
+ let path;
568
+ let fields;
569
+ try {
570
+ if (Object.keys(args.data).length === 0) return errorResult("data must contain at least one field to update.");
571
+ if (owner && owner in args.data && args.data[owner] !== uid) {
572
+ return errorResult(`${owner} can't be changed: it holds the owning user's uid.`);
573
+ }
574
+ path = documentPath(coll.segments, args, args.id);
575
+ fields = encodeFields(args.data, coll.config.fields);
576
+ } catch (err) {
577
+ return errorResult(err.message);
578
+ }
579
+ return toResult(() => client.update(path, fields));
580
+ }
581
+ );
582
+ }
583
+ function registerDeleteTool(server, coll, client, taken) {
584
+ const name = toolName("delete", coll.suffix, taken);
585
+ server.registerTool(
586
+ name,
587
+ {
588
+ description: `Delete one document from ${coll.key} by id.${describeCollection(coll)}`,
589
+ inputSchema: { ...paramShape(coll), id: import_zod2.z.string().describe("Document id") },
590
+ annotations: { readOnlyHint: false, destructiveHint: true }
591
+ },
592
+ async (args) => {
593
+ let path;
594
+ try {
595
+ path = documentPath(coll.segments, args, args.id);
596
+ } catch (err) {
597
+ return errorResult(err.message);
598
+ }
599
+ return toResult(() => client.delete(path));
600
+ }
601
+ );
602
+ }
603
+ function registerCallableTool(server, name, config, invokeCallable, taken) {
604
+ server.registerTool(
605
+ toolName("call", name, taken),
606
+ { description: config.description, inputSchema: jsonSchemaToZodShape(config.input) },
607
+ async (args) => toResult(() => invokeCallable(name, args))
608
+ );
609
+ }
610
+ function registerDocsTool(server, kb, cfg, incoming) {
611
+ const docsAllowed = cfg.docsAuth === "public" || incoming.has("authorization");
612
+ if (!docsAllowed || kb.isEmpty) return;
613
+ server.registerTool(
614
+ "search_docs",
615
+ {
616
+ description: "Search this app's documentation and product knowledge.",
617
+ inputSchema: { query: import_zod2.z.string().describe("Search query"), limit: import_zod2.z.number().int().min(1).max(20).optional() },
618
+ annotations: { readOnlyHint: true }
619
+ },
620
+ async ({ query, limit }) => {
621
+ const hits = kb.search(query, limit ?? 5);
622
+ const text = hits.length ? hits.map((h) => `[${h.chunk.source} \u2014 ${h.chunk.heading}]
623
+ ${h.chunk.text}`).join("\n\n---\n\n") : "No matching documentation found.";
624
+ return { content: [{ type: "text", text }] };
625
+ }
626
+ );
627
+ }
628
+ function errorResult(text) {
629
+ return { content: [{ type: "text", text }], isError: true };
630
+ }
631
+ async function toResult(run) {
632
+ let timer;
633
+ try {
634
+ const outcome = await Promise.race([
635
+ run(),
636
+ new Promise((_, reject) => {
637
+ timer = setTimeout(() => reject(new Error("timeout")), import_core.DISPATCH_TIMEOUT_MS);
638
+ })
639
+ ]);
640
+ const result = (0, import_core.toToolResult)(outcome);
641
+ return { content: [{ type: "text", text: result.text }], isError: result.isError };
642
+ } catch (err) {
643
+ const message = err.message === "timeout" ? `Tool call timed out after ${import_core.DISPATCH_TIMEOUT_MS / 1e3}s` : "Tool call failed reaching Firebase.";
644
+ return { content: [{ type: "text", text: message }], isError: true };
645
+ } finally {
646
+ clearTimeout(timer);
647
+ }
648
+ }
649
+
650
+ // src/node-bridge.ts
651
+ function toNodeHandler(handle) {
652
+ return async (req, res) => {
653
+ let response;
654
+ try {
655
+ response = await handle(await toWebRequest(req));
656
+ } catch (err) {
657
+ console.error("[exposify-firebase] MCP request failed", err);
658
+ response = Response.json({ error: "internal MCP server error" }, { status: 503 });
659
+ }
660
+ res.statusCode = response.status;
661
+ response.headers.forEach((value, name) => res.setHeader(name, value));
662
+ if (!response.body) {
663
+ res.end();
664
+ return;
665
+ }
666
+ const reader = response.body.getReader();
667
+ res.on("close", () => void reader.cancel().catch(() => {
668
+ }));
669
+ try {
670
+ for (; ; ) {
671
+ const { done, value } = await reader.read();
672
+ if (done) break;
673
+ res.write(value);
674
+ }
675
+ } catch {
676
+ } finally {
677
+ res.end();
678
+ }
679
+ };
680
+ }
681
+ async function toWebRequest(req) {
682
+ const headers = new Headers();
683
+ for (const [name, value] of Object.entries(req.headers)) {
684
+ if (typeof value === "string") headers.set(name, value);
685
+ else if (Array.isArray(value)) headers.set(name, value.join(", "));
686
+ }
687
+ const url = new URL(req.originalUrl ?? req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
688
+ const method = req.method ?? "GET";
689
+ if (method === "GET" || method === "HEAD") return new Request(url, { method, headers });
690
+ let body;
691
+ if (req.rawBody) body = new Uint8Array(req.rawBody);
692
+ else if (req.body !== void 0) body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
693
+ else body = await readStream(req);
694
+ headers.delete("content-length");
695
+ headers.delete("transfer-encoding");
696
+ return new Request(url, { method, headers, body });
697
+ }
698
+ async function readStream(req) {
699
+ const chunks = [];
700
+ for await (const chunk of req) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
701
+ return new Uint8Array(Buffer.concat(chunks));
702
+ }
703
+
704
+ // src/token.ts
705
+ function uidFromAuthorization(authorization) {
706
+ const match = authorization?.match(/^Bearer\s+([^.\s]+)\.([^.\s]+)\.[^.\s]*$/i);
707
+ if (!match) return null;
708
+ try {
709
+ const payload = JSON.parse(base64UrlDecode(match[2]));
710
+ const uid = typeof payload.user_id === "string" ? payload.user_id : payload.sub;
711
+ return typeof uid === "string" && uid.length > 0 ? uid : null;
712
+ } catch {
713
+ return null;
714
+ }
715
+ }
716
+ function base64UrlDecode(segment) {
717
+ const b64 = segment.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(segment.length / 4) * 4, "=");
718
+ const binary = atob(b64);
719
+ return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
720
+ }
721
+
722
+ // src/index.ts
723
+ var import_core3 = require("@exposify/core");
724
+ function aiReady(options = {}) {
725
+ return toNodeHandler(createFirebaseMcpHandler(options));
726
+ }
727
+ function createFirebaseMcpHandler(options = {}) {
728
+ const env = options.env ?? globalThis.process?.env ?? {};
729
+ const fetchImpl = options.fetchImpl ?? fetch;
730
+ const projectId = options.projectId ?? projectIdFromEnv(env);
731
+ const logReady = (0, import_core2.startupLogEnabled)(options.startupLog);
732
+ const functionName = env.K_SERVICE ?? env.FUNCTION_TARGET;
733
+ const endpoint = functionName ? `function ${functionName}` : "this function";
734
+ const envAuth = options.auth ? void 0 : (0, import_core2.hostedResolverFromEnv)();
735
+ const auth = options.auth ?? envAuth;
736
+ const authRequired = options.authRequired ?? (envAuth !== void 0 || (0, import_core2.isHostedResolver)(auth));
737
+ const cfg = resolveFirebaseConfig(options, (0, import_core2.isHostedResolver)(auth));
738
+ const authLabel = (0, import_core2.isHostedResolver)(auth) ? "hosted broker (EXPOSIFY_KEY)" : auth ? "custom resolver" : "passthrough (caller's Firebase ID token)";
739
+ if (!projectId) {
740
+ console.error((0, import_core2.formatFailed)(LOG_PREFIX, "no Firebase project id", "deployed functions get GCLOUD_PROJECT automatically; elsewhere pass { projectId }"));
741
+ } else if (cfg.collections.length === 0 && Object.keys(cfg.functions).length === 0) {
742
+ console.warn((0, import_core2.formatWarning)(LOG_PREFIX, "no collections or functions configured, so agents only get search_docs", "list collections in aiReady({ collections })"));
743
+ }
744
+ const baseUrl = projectId ? documentsBaseUrl(projectId, cfg.databaseId, env.FIRESTORE_EMULATOR_HOST) : "";
745
+ let kbPromise = null;
746
+ let docCount = 0;
747
+ const getKb = () => {
748
+ kbPromise ??= (async () => {
749
+ try {
750
+ const cwd = options.docsRoot ?? globalThis.process?.cwd() ?? ".";
751
+ const docs = await (0, import_node.loadDocsFromFs)(cfg.docs, cwd);
752
+ docCount = docs.length;
753
+ return import_core2.KnowledgeBase.build(docs);
754
+ } catch {
755
+ return import_core2.KnowledgeBase.build([]);
756
+ }
757
+ })();
758
+ return kbPromise;
759
+ };
760
+ let coldStartLogged = false;
761
+ const resolvedAuth = /* @__PURE__ */ new WeakMap();
762
+ const core = (0, import_core2.createWebMcpHandler)(
763
+ {
764
+ getServer: async (req) => {
765
+ if (!projectId) throw new Error("No Firebase project id: set GCLOUD_PROJECT or pass { projectId }.");
766
+ const incoming = req.headers;
767
+ const authorization = (resolvedAuth.get(req) ?? incoming).get("authorization");
768
+ const kb = await getKb();
769
+ if (!coldStartLogged) {
770
+ coldStartLogged = true;
771
+ if (logReady) {
772
+ try {
773
+ console.log((0, import_core2.formatReady)(LOG_PREFIX, { endpoint, tools: firebaseToolsPhrase(cfg, docCount > 0), docs: docCount, auth: authLabel }));
774
+ } catch {
775
+ }
776
+ }
777
+ }
778
+ const invokeCallable = async (name, data) => {
779
+ const url = cfg.functions[name]?.url ?? callableUrl(name, projectId, cfg.functionsRegion, env);
780
+ const res = await fetchImpl(url, {
781
+ method: "POST",
782
+ headers: { "content-type": "application/json", ...authorization ? { authorization } : {} },
783
+ body: JSON.stringify({ data: data ?? {} })
784
+ });
785
+ const text = await res.text();
786
+ if (!res.ok) return { status: res.status, bodyText: text };
787
+ try {
788
+ return { status: res.status, bodyText: JSON.stringify(JSON.parse(text).result ?? null) };
789
+ } catch {
790
+ return { status: res.status, bodyText: text };
791
+ }
792
+ };
793
+ return buildFirebaseMcpServer({
794
+ appName: "exposify-firebase",
795
+ appVersion: "0.0.0",
796
+ cfg,
797
+ client: createFirestoreClient(baseUrl, authorization, fetchImpl),
798
+ kb,
799
+ incoming,
800
+ uid: uidFromAuthorization(authorization),
801
+ invokeCallable
802
+ });
803
+ }
804
+ },
805
+ { rateLimit: cfg.rateLimit, rateWindowMs: cfg.rateWindowMs }
806
+ );
807
+ if (!auth) return core;
808
+ const brokerGate = authRequired ? (0, import_core2.createBrokerGate)(auth) : null;
809
+ return async (req) => {
810
+ let open = false;
811
+ if (brokerGate) {
812
+ const own = (0, import_core2.publicRequestUrl)(req.url, req.headers);
813
+ const isPrm = own.endsWith(import_core2.PRM_PATH);
814
+ const resource = brokerGate.resourceFor(functionPublicUrl(isPrm ? own.slice(0, -import_core2.PRM_PATH.length) : own, functionName));
815
+ open = await (0, import_core2.reportAppLogin)(auth, resource, null, { stacks: ["firebase-admin"], why: "Firebase Auth sign-in for agents is the Firebase preset in the dashboard's sign-in methods.", log: logReady }) === "open";
816
+ if (open) {
817
+ if (isPrm) return new Response("not found", { status: 404 });
818
+ } else {
819
+ if (isPrm) return brokerGate.metadataResponse(req.method, resource);
820
+ const result = await brokerGate.check(req.headers, resource, `${resource}${import_core2.PRM_PATH}`);
821
+ if (!result.ok) return result.response;
822
+ }
823
+ }
824
+ let resolved;
825
+ try {
826
+ resolved = await auth(req.headers, {});
827
+ } catch (err) {
828
+ console.error(`${LOG_PREFIX} auth resolver failed`, err);
829
+ return Response.json({ error: "internal MCP server error" }, { status: 503 });
830
+ }
831
+ if (authRequired && !open && !resolved.get("authorization")) {
832
+ return Response.json({ error: "unauthorized" }, { status: 401, headers: { "www-authenticate": "Bearer" } });
833
+ }
834
+ resolvedAuth.set(req, resolved);
835
+ return core(req);
836
+ };
837
+ }
838
+ function functionPublicUrl(own, functionName) {
839
+ const url = new URL(own);
840
+ const path = url.pathname.replace(/\/+$/, "");
841
+ if (functionName && url.hostname.endsWith(".cloudfunctions.net") && path !== `/${functionName}` && !path.startsWith(`/${functionName}/`)) {
842
+ url.pathname = `/${functionName}${path}`;
843
+ }
844
+ return url.toString();
845
+ }
846
+ function callableUrl(name, projectId, region, env) {
847
+ const emulator = env.FUNCTIONS_EMULATOR_HOST;
848
+ if (emulator) return `http://${emulator.replace(/^https?:\/\//, "")}/${projectId}/${region}/${name}`;
849
+ return `https://${region}-${projectId}.cloudfunctions.net/${name}`;
850
+ }
851
+ function firebaseToolsPhrase(cfg, hasDocs) {
852
+ const reads = cfg.collections.length;
853
+ const writes = cfg.collections.reduce((n, c) => n + c.writes.size, 0);
854
+ const callables = Object.keys(cfg.functions).length;
855
+ const parts = [`${reads} list`, `${reads} get`];
856
+ if (writes) parts.push(`${writes} write`);
857
+ if (callables) parts.push(`${callables} callable`);
858
+ if (hasDocs) parts.push("search_docs");
859
+ const total = reads * 2 + writes + callables + (hasDocs ? 1 : 0);
860
+ return `${total} tool${total === 1 ? "" : "s"} (${parts.join(", ")})`;
861
+ }
862
+ // Annotate the CommonJS export names for ESM import in node:
863
+ 0 && (module.exports = {
864
+ aiReady,
865
+ callableUrl,
866
+ completeMcpLogin,
867
+ createFirebaseMcpHandler,
868
+ createHostedAuthResolver,
869
+ firebaseToolsPhrase,
870
+ functionPublicUrl,
871
+ hostedBroker,
872
+ parseCollectionKey,
873
+ toNodeHandler
874
+ });