@exposify/firebase 0.0.0-stage → 0.0.1
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 +173 -2
- package/dist/cli.js +230 -0
- package/dist/index.cjs +868 -0
- package/dist/index.d.cts +178 -0
- package/dist/index.d.ts +178 -0
- package/dist/index.js +846 -0
- package/package.json +52 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Exposify
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,174 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @exposify/firebase: setup guide
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Make a Firebase app usable by AI agents: one Cloud Function serves MCP, with
|
|
4
|
+
tools for the Firestore collections you name, the callable functions you
|
|
5
|
+
register, and your bundled docs. The caller's Firebase ID token goes
|
|
6
|
+
straight to the Firestore REST API, so **your Security Rules are the
|
|
7
|
+
authorization layer**. The agent can do exactly what that signed-in user can
|
|
8
|
+
do from your app, nothing more. The function never uses the Admin SDK.
|
|
9
|
+
|
|
10
|
+
For the design rationale, see
|
|
11
|
+
`docs/superpowers/specs/2026-10-10-exposify-firebase-design.md` in the repo
|
|
12
|
+
root.
|
|
13
|
+
|
|
14
|
+
## Prerequisites
|
|
15
|
+
|
|
16
|
+
- A Firebase project with Cloud Functions (`firebase init functions`; Node 20+,
|
|
17
|
+
TypeScript or JavaScript) and Firestore.
|
|
18
|
+
- The Firebase CLI for `firebase deploy`.
|
|
19
|
+
|
|
20
|
+
## 1. Scaffold
|
|
21
|
+
|
|
22
|
+
From your Firebase project root (where `firebase.json` is):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx exposify-firebase init
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
It reads `firebase.json` to find your functions directory and
|
|
29
|
+
`firestore.rules` to propose collections, then:
|
|
30
|
+
|
|
31
|
+
- writes `functions/src/mcp.ts` (or `mcp.js` for JavaScript projects): the
|
|
32
|
+
MCP function, with every collection found in your rules listed as
|
|
33
|
+
readable and all writes commented out;
|
|
34
|
+
- adds `export { mcp } from "./mcp.js";` to your functions entry file.
|
|
35
|
+
|
|
36
|
+
It never overwrites an existing `mcp.ts`, so re-running is safe. `--dry-run`
|
|
37
|
+
prints the plan without writing.
|
|
38
|
+
|
|
39
|
+
The rules reading is a proposal, not an analysis: a `match /todos/{id}`
|
|
40
|
+
block becomes the `todos` collection, and a rule like
|
|
41
|
+
`resource.data.uid == request.auth.uid` becomes `owner: "uid"`. Review the
|
|
42
|
+
list before deploying.
|
|
43
|
+
|
|
44
|
+
## 2. Install and deploy
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
cd functions && npm install @exposify/firebase
|
|
48
|
+
firebase deploy --only functions:mcp
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 3. Connect an agent
|
|
52
|
+
|
|
53
|
+
Point an MCP client at the function's URL
|
|
54
|
+
(`https://<region>-<project>.cloudfunctions.net/mcp`) with the header
|
|
55
|
+
`Authorization: Bearer <Firebase ID token>`. In your app,
|
|
56
|
+
`await auth.currentUser.getIdToken()` returns one. ID tokens expire after an
|
|
57
|
+
hour, so this is good for a quick try, not a lasting connection.
|
|
58
|
+
|
|
59
|
+
Without a token, Firestore evaluates rules with `request.auth == null`, so
|
|
60
|
+
the agent gets whatever your rules allow anonymous users.
|
|
61
|
+
|
|
62
|
+
### Lasting connections: the hosted broker
|
|
63
|
+
|
|
64
|
+
Set `EXPOSIFY_KEY` (the secret key from your project in the Exposify
|
|
65
|
+
dashboard) in the function's environment and the broker turns on by itself,
|
|
66
|
+
with no code change. Users then sign in from their MCP client: a request
|
|
67
|
+
without a connection gets a `401` that points the client at
|
|
68
|
+
`<function URL>/.well-known/oauth-protected-resource`, which names the
|
|
69
|
+
broker as the authorization server, and the client sends the user through
|
|
70
|
+
sign-in. Tool calls then reach Firestore with the `Authorization` the broker
|
|
71
|
+
resolves for that user, never the broker token itself, so your rules still
|
|
72
|
+
decide. Requests without a connection are refused rather
|
|
73
|
+
than run anonymously; pass `authRequired: false` to allow them.
|
|
74
|
+
|
|
75
|
+
`auth: hostedBroker()` does the same explicitly. If the function is
|
|
76
|
+
reached through a URL other than the one it sees (a custom domain or a
|
|
77
|
+
Hosting rewrite), set it with `hostedBroker({ resource: "https://…/mcp" })`.
|
|
78
|
+
Behind `*.cloudfunctions.net/<name>` and `*.run.app` the right URL is worked
|
|
79
|
+
out for you. See [`docs/hosted-broker.md`](../../docs/hosted-broker.md).
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { onRequest } from "firebase-functions/v2/https";
|
|
85
|
+
import { aiReady } from "@exposify/firebase";
|
|
86
|
+
|
|
87
|
+
export const mcp = onRequest(
|
|
88
|
+
aiReady({
|
|
89
|
+
collections: {
|
|
90
|
+
todos: {
|
|
91
|
+
description: "The user's todo items",
|
|
92
|
+
owner: "uid",
|
|
93
|
+
fields: {
|
|
94
|
+
title: { type: "string" },
|
|
95
|
+
done: { type: "boolean" },
|
|
96
|
+
due: { type: "string", format: "date-time" },
|
|
97
|
+
},
|
|
98
|
+
required: ["title"],
|
|
99
|
+
},
|
|
100
|
+
"users/{userId}/notes": {},
|
|
101
|
+
},
|
|
102
|
+
writes: ["todos.*", "users/{userId}/notes.create"],
|
|
103
|
+
functions: {
|
|
104
|
+
refundOrder: {
|
|
105
|
+
description: "Refund an order. Reversible within 30 days.",
|
|
106
|
+
input: { properties: { orderId: { type: "string" } }, required: ["orderId"] },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| Option | Default | What it does |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `collections` | none | Collections agents can use, keyed by path. `{param}` placeholders make subcollections; each becomes a tool argument. An array of names is shorthand for no per-collection config. |
|
|
116
|
+
| `collections[k].fields` | none | Field schema (a JSON Schema subset). Limits filters and `select` to these names and types writes. `format: "date-time"` stores a string as a Firestore timestamp. Without it, any field name and any object are accepted. |
|
|
117
|
+
| `collections[k].owner` | none | The field holding the owner's uid. `list_*` filters on it, `create_*` sets it from the token, `update_*` refuses to change it. |
|
|
118
|
+
| `writes` | `[]` | `"<collection>.create"`, `.update`, `.delete` or `.*`. |
|
|
119
|
+
| `functions` | `{}` | Callable functions (`onCall`), invoked with the caller's token. `url` overrides the address. |
|
|
120
|
+
| `functionsRegion` | `us-central1` | Region of those callable functions. |
|
|
121
|
+
| `docs` | `["docs/**/*.md"]` | Markdown files, relative to the functions directory, served through `search_docs`. |
|
|
122
|
+
| `docsAuth` | `"public"` (`"required"` with the hosted broker) | `"required"` hides `search_docs` from callers without a token. |
|
|
123
|
+
| `maxDocs` | `100` | Cap on documents per `list_*` call. |
|
|
124
|
+
| `projectId`, `databaseId` | from the environment, `(default)` | Set automatically in deployed functions. |
|
|
125
|
+
| `auth`, `authRequired` | the hosted broker when `EXPOSIFY_KEY` is set, else none | A resolver that turns the incoming headers into the ones sent to Firebase (e.g. `hostedBroker()`, Exposify's hosted broker), and whether a missing result is a 401 (default: true with the hosted broker, false otherwise). |
|
|
126
|
+
|
|
127
|
+
## Tools
|
|
128
|
+
|
|
129
|
+
For each collection (tool names use the collection ids, snake_cased:
|
|
130
|
+
`users/{userId}/notes` → `users_notes`):
|
|
131
|
+
|
|
132
|
+
- `list_<c>`: `where` (operators as in the client SDK: `==`, `!=`, `<`, `<=`,
|
|
133
|
+
`>`, `>=`, `array-contains`, `in`, `not-in`, `array-contains-any`),
|
|
134
|
+
`orderBy`, `limit`, `select`.
|
|
135
|
+
- `get_<c>`: one document by `id`.
|
|
136
|
+
- `create_<c>`, `update_<c>`, `delete_<c>`: only when opted in. Updates and
|
|
137
|
+
deletes take one document id; there are no bulk writes. Updates change
|
|
138
|
+
only the given fields and fail if the document doesn't exist.
|
|
139
|
+
- `call_<fn>`: each registered callable function.
|
|
140
|
+
- `search_docs`: when docs are bundled.
|
|
141
|
+
|
|
142
|
+
Documents come back as `{ id, path, data, createTime, updateTime }`, with
|
|
143
|
+
timestamps as ISO strings and references as document paths.
|
|
144
|
+
|
|
145
|
+
Errors from Firestore reach the agent as they are (`HTTP 403: Missing or
|
|
146
|
+
insufficient permissions`, or a missing-index error with the link to create
|
|
147
|
+
the index), so it can correct its call. 5xx errors are replaced by a generic
|
|
148
|
+
message.
|
|
149
|
+
|
|
150
|
+
## Common rule shapes
|
|
151
|
+
|
|
152
|
+
- **Owner field** (`allow read: if resource.data.uid == request.auth.uid`):
|
|
153
|
+
set `owner: "uid"`. Firestore rejects a query that doesn't filter on the
|
|
154
|
+
field, so without `owner` the agent has to add the filter itself (the 403
|
|
155
|
+
it gets says so).
|
|
156
|
+
- **Per-user subcollection** (`match /users/{userId}/notes/{id}` with
|
|
157
|
+
`request.auth.uid == userId`): key the collection
|
|
158
|
+
`"users/{userId}/notes"`; the agent passes its own uid as `userId`.
|
|
159
|
+
|
|
160
|
+
## Local testing with the emulators
|
|
161
|
+
|
|
162
|
+
Under `firebase emulators:start --only functions,firestore`, the Functions
|
|
163
|
+
emulator sets `FIRESTORE_EMULATOR_HOST`, so the MCP function talks to the
|
|
164
|
+
Firestore emulator and its rules. The function's URL is
|
|
165
|
+
`http://127.0.0.1:5001/<project>/us-central1/mcp`. The emulator accepts
|
|
166
|
+
unsigned ID tokens, which is how this package's integration test runs two
|
|
167
|
+
users against real rules (`test/integration/`).
|
|
168
|
+
|
|
169
|
+
## Not supported yet
|
|
170
|
+
|
|
171
|
+
Realtime Database, Storage, collection-group queries, transactions and
|
|
172
|
+
batched writes, pagination cursors, inferring field schemas from existing
|
|
173
|
+
documents, and the `oauth` option (Firebase Auth isn't an OAuth
|
|
174
|
+
authorization server).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/init.ts
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
+
import { dirname, join } from "path";
|
|
6
|
+
|
|
7
|
+
// bin/rules.ts
|
|
8
|
+
function collectionsFromRules(source) {
|
|
9
|
+
const text = stripComments(source);
|
|
10
|
+
const blocks = [];
|
|
11
|
+
collectBlocks(text, blocks);
|
|
12
|
+
const out = /* @__PURE__ */ new Map();
|
|
13
|
+
for (const block of blocks) {
|
|
14
|
+
const docsAt = block.path.indexOf("documents");
|
|
15
|
+
if (docsAt === -1) continue;
|
|
16
|
+
const segments = block.path.slice(docsAt + 1);
|
|
17
|
+
if (segments.length === 0 || segments.length % 2 !== 0) continue;
|
|
18
|
+
if (segments.some((s) => /^\{[^}]*=\*\*\}$/.test(s))) continue;
|
|
19
|
+
const last = segments[segments.length - 1];
|
|
20
|
+
if (!/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(last)) continue;
|
|
21
|
+
const collectionSegments = segments.slice(0, -1).map((seg, i) => i % 2 === 1 ? renameReserved(seg, segments[i - 1]) : seg);
|
|
22
|
+
if (collectionSegments.some((s, i) => i % 2 === 0 && s.includes("{"))) continue;
|
|
23
|
+
const key = collectionSegments.join("/");
|
|
24
|
+
if (out.has(key)) continue;
|
|
25
|
+
const owner = ownerField(block.body);
|
|
26
|
+
out.set(key, owner ? { key, owner } : { key });
|
|
27
|
+
}
|
|
28
|
+
return [...out.values()];
|
|
29
|
+
}
|
|
30
|
+
function renameReserved(segment, collection) {
|
|
31
|
+
return segment === "{id}" ? `{${collection.replace(/[^A-Za-z0-9_]/g, "_")}Id}` : segment;
|
|
32
|
+
}
|
|
33
|
+
function ownerField(body) {
|
|
34
|
+
const m = body.match(/resource\.data\.([A-Za-z_][A-Za-z0-9_]*)\s*==\s*request\.auth\.uid/) ?? body.match(/request\.auth\.uid\s*==\s*resource\.data\.([A-Za-z_][A-Za-z0-9_]*)/);
|
|
35
|
+
return m?.[1];
|
|
36
|
+
}
|
|
37
|
+
function collectBlocks(text, out) {
|
|
38
|
+
const stack = [{ block: null, path: [] }];
|
|
39
|
+
const matchRe = /^match\s+(\/(?:[^\s{]|\{[^}\s]*\})*)\s*\{/;
|
|
40
|
+
const enclosingBlock = () => {
|
|
41
|
+
for (let j = stack.length - 1; j >= 0; j--) if (stack[j].block) return stack[j].block;
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < text.length; i++) {
|
|
45
|
+
const c = text[i];
|
|
46
|
+
if (c === "m" && !/[A-Za-z0-9_.]/.test(text[i - 1] ?? " ")) {
|
|
47
|
+
const m = text.slice(i, i + 512).match(matchRe);
|
|
48
|
+
if (m) {
|
|
49
|
+
const path = [...stack[stack.length - 1].path, ...m[1].split("/").filter(Boolean)];
|
|
50
|
+
const block2 = { path, body: "" };
|
|
51
|
+
out.push(block2);
|
|
52
|
+
stack.push({ block: block2, path });
|
|
53
|
+
i += m[0].length - 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (c === "{") {
|
|
58
|
+
const block2 = enclosingBlock();
|
|
59
|
+
if (block2) block2.body += c;
|
|
60
|
+
stack.push({ block: null, path: stack[stack.length - 1].path });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (c === "}") {
|
|
64
|
+
const popped = stack.length > 1 ? stack.pop() : null;
|
|
65
|
+
const block2 = enclosingBlock();
|
|
66
|
+
if (block2 && !popped?.block) block2.body += c;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const block = enclosingBlock();
|
|
70
|
+
if (block) block.body += c;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function stripComments(source) {
|
|
74
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// bin/init.ts
|
|
78
|
+
var GENERATED_MARKER = "Generated by `exposify-firebase init`";
|
|
79
|
+
function planInit(root) {
|
|
80
|
+
const warnings = [];
|
|
81
|
+
const firebaseJsonPath = join(root, "firebase.json");
|
|
82
|
+
let firebaseJson = {};
|
|
83
|
+
if (existsSync(firebaseJsonPath)) {
|
|
84
|
+
try {
|
|
85
|
+
firebaseJson = JSON.parse(readFileSync(firebaseJsonPath, "utf8"));
|
|
86
|
+
} catch {
|
|
87
|
+
warnings.push("firebase.json isn't valid JSON; assuming the default functions/ directory and firestore.rules");
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
warnings.push("no firebase.json here; run this from your Firebase project root (`firebase init functions` creates one)");
|
|
91
|
+
}
|
|
92
|
+
const functionsCfg = Array.isArray(firebaseJson.functions) ? firebaseJson.functions.find((f) => !f.codebase || f.codebase === "default") ?? firebaseJson.functions[0] : firebaseJson.functions;
|
|
93
|
+
const functionsDir = (functionsCfg?.source ?? "functions").replace(/\/+$/, "");
|
|
94
|
+
const firestoreCfg = Array.isArray(firebaseJson.firestore) ? firebaseJson.firestore[0] : firebaseJson.firestore;
|
|
95
|
+
const rulesRel = firestoreCfg?.rules ?? "firestore.rules";
|
|
96
|
+
const pkgPath = join(root, functionsDir, "package.json");
|
|
97
|
+
let pkg = {};
|
|
98
|
+
if (existsSync(pkgPath)) {
|
|
99
|
+
try {
|
|
100
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
101
|
+
} catch {
|
|
102
|
+
warnings.push(`${functionsDir}/package.json isn't valid JSON`);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
warnings.push(`no ${functionsDir}/package.json; run \`firebase init functions\` first`);
|
|
106
|
+
}
|
|
107
|
+
if (pkg.dependencies && !pkg.dependencies["firebase-functions"]) {
|
|
108
|
+
warnings.push(`${functionsDir}/package.json doesn't depend on firebase-functions; the generated file imports it`);
|
|
109
|
+
}
|
|
110
|
+
const kind = existsSync(join(root, functionsDir, "tsconfig.json")) ? "ts" : pkg.type === "module" ? "esm" : "cjs";
|
|
111
|
+
const entryRel = kind === "ts" ? "src/index.ts" : pkg.main ?? "index.js";
|
|
112
|
+
const entryFile = `${functionsDir}/${entryRel.replace(/^\.\//, "")}`;
|
|
113
|
+
const entryDir = entryFile.slice(0, entryFile.lastIndexOf("/"));
|
|
114
|
+
const mcpFile = `${entryDir}/mcp.${kind === "ts" ? "ts" : "js"}`;
|
|
115
|
+
let collections = [];
|
|
116
|
+
const rulesPath = join(root, rulesRel);
|
|
117
|
+
const rulesFile = existsSync(rulesPath) ? rulesRel : null;
|
|
118
|
+
if (rulesFile) {
|
|
119
|
+
collections = collectionsFromRules(readFileSync(rulesPath, "utf8"));
|
|
120
|
+
if (collections.length === 0) warnings.push(`found no collection match blocks in ${rulesRel}; add collections to ${mcpFile} by hand`);
|
|
121
|
+
} else {
|
|
122
|
+
warnings.push(`no ${rulesRel}; add collections to ${mcpFile} by hand`);
|
|
123
|
+
}
|
|
124
|
+
const actions = [];
|
|
125
|
+
if (existsSync(join(root, mcpFile))) {
|
|
126
|
+
actions.push({ kind: "skip", file: mcpFile, reason: "already exists; leaving it as is" });
|
|
127
|
+
} else {
|
|
128
|
+
actions.push({ kind: "write", file: mcpFile, reason: "the MCP function", content: mcpSource(kind, collections) });
|
|
129
|
+
}
|
|
130
|
+
const entryPath = join(root, entryFile);
|
|
131
|
+
const entrySource = existsSync(entryPath) ? readFileSync(entryPath, "utf8") : null;
|
|
132
|
+
if (entrySource === null) {
|
|
133
|
+
warnings.push(`no ${entryFile}; export mcp from your functions entry file yourself`);
|
|
134
|
+
} else if (/(['"])\.\/mcp(\.js)?\1/.test(entrySource)) {
|
|
135
|
+
actions.push({ kind: "skip", file: entryFile, reason: "already exports mcp" });
|
|
136
|
+
} else {
|
|
137
|
+
actions.push({ kind: "append", file: entryFile, reason: "export the new function", content: exportLine(kind) });
|
|
138
|
+
}
|
|
139
|
+
return { functionsDir, kind, mcpFile, entryFile, rulesFile, collections, actions, warnings };
|
|
140
|
+
}
|
|
141
|
+
function exportLine(kind) {
|
|
142
|
+
if (kind === "cjs") return 'exports.mcp = require("./mcp").mcp;\n';
|
|
143
|
+
return 'export { mcp } from "./mcp.js";\n';
|
|
144
|
+
}
|
|
145
|
+
function mcpSource(kind, collections) {
|
|
146
|
+
const imports = kind === "cjs" ? 'const { onRequest } = require("firebase-functions/v2/https");\nconst { aiReady } = require("@exposify/firebase");\n' : 'import { onRequest } from "firebase-functions/v2/https";\nimport { aiReady } from "@exposify/firebase";\n';
|
|
147
|
+
const entries = collections.length ? collections.map((c) => ` ${JSON.stringify(c.key)}: {${c.owner ? ` owner: ${JSON.stringify(c.owner)} ` : ""}},`).join("\n") : ' // "todos": { owner: "uid" },';
|
|
148
|
+
const writes = collections.length ? collections.map((c) => ` // ${JSON.stringify(`${c.key}.*`)},`).join("\n") : ' // "todos.*",';
|
|
149
|
+
const exportStart = kind === "cjs" ? "exports.mcp = onRequest(" : "export const mcp = onRequest(";
|
|
150
|
+
return `// ${GENERATED_MARKER}. Yours to edit; re-running init won't overwrite it.
|
|
151
|
+
//
|
|
152
|
+
// Agents connect to this function's URL with a signed-in user's Firebase ID
|
|
153
|
+
// token. Every Firestore call runs as that user, so your Security Rules
|
|
154
|
+
// decide what they can read and write.
|
|
155
|
+
${imports}
|
|
156
|
+
${exportStart}
|
|
157
|
+
aiReady({
|
|
158
|
+
// Collections agents can read (list_* and get_* tools). Proposed from
|
|
159
|
+
// your firestore.rules; remove any agents shouldn't see. \`owner\` is the
|
|
160
|
+
// field your rules compare to request.auth.uid.
|
|
161
|
+
collections: {
|
|
162
|
+
${entries}
|
|
163
|
+
},
|
|
164
|
+
// Writes are opt-in: uncomment to add create_*, update_* and delete_* tools.
|
|
165
|
+
writes: [
|
|
166
|
+
${writes}
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
`;
|
|
171
|
+
}
|
|
172
|
+
function applyPlan(root, plan) {
|
|
173
|
+
for (const action of plan.actions) {
|
|
174
|
+
const path = join(root, action.file);
|
|
175
|
+
if (action.kind === "write") {
|
|
176
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
177
|
+
writeFileSync(path, action.content, { flag: "wx" });
|
|
178
|
+
} else if (action.kind === "append") {
|
|
179
|
+
appendFileSync(path, `
|
|
180
|
+
${action.content}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function nextSteps(plan) {
|
|
185
|
+
return `Next:
|
|
186
|
+
1. cd ${plan.functionsDir} && npm install @exposify/firebase
|
|
187
|
+
2. Review ${plan.mcpFile}: which collections agents see, and which writes to allow.
|
|
188
|
+
3. firebase deploy --only functions:mcp
|
|
189
|
+
4. Point an MCP client at the function's URL (the deploy prints it), with a
|
|
190
|
+
header "Authorization: Bearer <ID token>". In your app,
|
|
191
|
+
await auth.currentUser.getIdToken() gives you one; it expires after an
|
|
192
|
+
hour. For long-lived agent access, use Exposify's hosted broker:
|
|
193
|
+
auth: hostedBroker() with EXPOSIFY_KEY set (see docs/hosted-broker.md).`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// bin/cli.ts
|
|
197
|
+
var USAGE = `Usage: exposify-firebase init [--dry-run]
|
|
198
|
+
|
|
199
|
+
Run from your Firebase project root (where firebase.json is). Writes an MCP
|
|
200
|
+
function to your functions source directory, with collections proposed from
|
|
201
|
+
firestore.rules (reads only; writes are commented out), and exports it from
|
|
202
|
+
your functions entry file. Safe to re-run: existing files are left alone.`;
|
|
203
|
+
function main(argv) {
|
|
204
|
+
const [command, ...flags] = argv;
|
|
205
|
+
if (command !== "init" || flags.some((f) => f !== "--dry-run")) {
|
|
206
|
+
console.log(USAGE);
|
|
207
|
+
return command === "--help" || command === "-h" ? 0 : 1;
|
|
208
|
+
}
|
|
209
|
+
const root = process.cwd();
|
|
210
|
+
const plan = planInit(root);
|
|
211
|
+
for (const w of plan.warnings) console.warn(`warning: ${w}`);
|
|
212
|
+
if (plan.collections.length) {
|
|
213
|
+
console.log(`Collections from ${plan.rulesFile}: ${plan.collections.map((c) => c.key).join(", ")}`);
|
|
214
|
+
}
|
|
215
|
+
for (const a of plan.actions) {
|
|
216
|
+
const verb = a.kind === "write" ? "create" : a.kind === "append" ? "update" : "skip ";
|
|
217
|
+
console.log(` ${verb} ${a.file} (${a.reason})`);
|
|
218
|
+
}
|
|
219
|
+
if (flags.includes("--dry-run")) return 0;
|
|
220
|
+
try {
|
|
221
|
+
applyPlan(root, plan);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.error(`init failed: ${err.message}`);
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
console.log(`
|
|
227
|
+
${nextSteps(plan)}`);
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
process.exitCode = main(process.argv.slice(2));
|