fingerprint-platform-mcp 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/dist/cli.js +1094 -0
- package/dist/cli.js.map +1 -0
- package/dist/docs/api-keys.md +38 -0
- package/dist/docs/cloudflare-proxy.md +45 -0
- package/dist/docs/dns-cname.md +40 -0
- package/dist/docs/events-vs-identify.md +41 -0
- package/dist/docs/origin-allowlist.md +34 -0
- package/dist/docs/sync-vs-async.md +42 -0
- package/package.json +50 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/server.ts
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
|
|
9
|
+
// src/tools/analyze.ts
|
|
10
|
+
import { resolve as resolve3 } from "path";
|
|
11
|
+
|
|
12
|
+
// ../../packages/sdk-snippets/dist/snippet-for-framework.js
|
|
13
|
+
var FRAMEWORKS = [
|
|
14
|
+
{ id: "javascript", label: "JavaScript" },
|
|
15
|
+
{ id: "javascript-spa", label: "JavaScript SPA" },
|
|
16
|
+
{ id: "nextjs", label: "Next.js" },
|
|
17
|
+
{ id: "react", label: "React" },
|
|
18
|
+
{ id: "angular", label: "Angular" },
|
|
19
|
+
{ id: "vue", label: "Vue.js" },
|
|
20
|
+
{ id: "preact", label: "Preact" },
|
|
21
|
+
{ id: "svelte", label: "Svelte" }
|
|
22
|
+
];
|
|
23
|
+
function frameworkLabel(id) {
|
|
24
|
+
return FRAMEWORKS.find((f) => f.id === id)?.label ?? id;
|
|
25
|
+
}
|
|
26
|
+
function initSnippet(input) {
|
|
27
|
+
const { framework, source, apiKey, collectorUrl } = input;
|
|
28
|
+
const config = ` collectorUrl: '${collectorUrl}',
|
|
29
|
+
projectKey: '${apiKey}',`;
|
|
30
|
+
if (source === "cdn") {
|
|
31
|
+
return `${cdnPlacementComment(framework)}
|
|
32
|
+
<script>
|
|
33
|
+
// Initialize the agent at application startup.
|
|
34
|
+
const fp = window.FP.init({
|
|
35
|
+
${config}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Get the visitor and event identifier when you need it.
|
|
39
|
+
fp.identify({ event: 'pageview' }).then((r) => {
|
|
40
|
+
if (r.visitorId) console.log(r.eventId, r.visitorId, r.suspectScore);
|
|
41
|
+
else console.log('event queued:', r.eventId);
|
|
42
|
+
});
|
|
43
|
+
</script>`;
|
|
44
|
+
}
|
|
45
|
+
switch (framework) {
|
|
46
|
+
case "nextjs":
|
|
47
|
+
return `// app/_components/FpClient.tsx \u2014 client component
|
|
48
|
+
'use client';
|
|
49
|
+
import { useEffect } from 'react';
|
|
50
|
+
|
|
51
|
+
export function FpClient() {
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
let cancelled = false;
|
|
54
|
+
// Dynamic import keeps the SDK out of the server bundle \u2014 Next.js
|
|
55
|
+
// SSR has no Window, the SDK throws on the canvas calls there.
|
|
56
|
+
import('fingerprint-platform-sdk').then(({ init }) => {
|
|
57
|
+
if (cancelled) return;
|
|
58
|
+
const fp = init({
|
|
59
|
+
${config.replace(/^/gm, " ")}
|
|
60
|
+
});
|
|
61
|
+
void fp.identify({ event: 'pageview' });
|
|
62
|
+
});
|
|
63
|
+
return () => { cancelled = true; };
|
|
64
|
+
}, []);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Then mount <FpClient /> in your root layout.`;
|
|
69
|
+
case "react":
|
|
70
|
+
return `import { useEffect } from 'react';
|
|
71
|
+
import { init } from 'fingerprint-platform-sdk';
|
|
72
|
+
|
|
73
|
+
export function useFingerprint() {
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const fp = init({
|
|
76
|
+
${config.replace(/^/gm, " ")}
|
|
77
|
+
});
|
|
78
|
+
fp.identify({ event: 'pageview' }).then((r) => {
|
|
79
|
+
if (r.visitorId) console.log(r.eventId, r.visitorId, r.suspectScore);
|
|
80
|
+
else console.log('event queued:', r.eventId);
|
|
81
|
+
});
|
|
82
|
+
}, []);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Call useFingerprint() once in your root component.`;
|
|
86
|
+
case "vue":
|
|
87
|
+
return `// plugins/fingerprint.ts
|
|
88
|
+
import { init } from 'fingerprint-platform-sdk';
|
|
89
|
+
|
|
90
|
+
const fp = init({
|
|
91
|
+
${config}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export default {
|
|
95
|
+
install(app) {
|
|
96
|
+
app.config.globalProperties.$fp = fp;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Then in main.ts:
|
|
101
|
+
// app.use(fpPlugin);
|
|
102
|
+
// And from any component:
|
|
103
|
+
// this.$fp.identify({ event: 'pageview' });`;
|
|
104
|
+
case "angular":
|
|
105
|
+
return `// fingerprint.service.ts
|
|
106
|
+
import { Injectable } from '@angular/core';
|
|
107
|
+
import { init, type FpSdk } from 'fingerprint-platform-sdk';
|
|
108
|
+
|
|
109
|
+
@Injectable({ providedIn: 'root' })
|
|
110
|
+
export class FingerprintService {
|
|
111
|
+
readonly fp: FpSdk = init({
|
|
112
|
+
${config.replace(/^/gm, " ")}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Inject in components and call: this.fp.fp.identify({ event: 'pageview' });`;
|
|
117
|
+
case "svelte":
|
|
118
|
+
return `// src/lib/fingerprint.ts
|
|
119
|
+
import { init } from 'fingerprint-platform-sdk';
|
|
120
|
+
|
|
121
|
+
export const fp = init({
|
|
122
|
+
${config}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// In any +page.svelte:
|
|
126
|
+
// <script lang="ts">
|
|
127
|
+
// import { onMount } from 'svelte';
|
|
128
|
+
// import { fp } from '$lib/fingerprint';
|
|
129
|
+
// onMount(() => { void fp.identify({ event: 'pageview' }); });
|
|
130
|
+
// </script>`;
|
|
131
|
+
case "preact":
|
|
132
|
+
return `import { useEffect } from 'preact/hooks';
|
|
133
|
+
import { init } from 'fingerprint-platform-sdk';
|
|
134
|
+
|
|
135
|
+
export function useFingerprint() {
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const fp = init({
|
|
138
|
+
${config.replace(/^/gm, " ")}
|
|
139
|
+
});
|
|
140
|
+
void fp.identify({ event: 'pageview' });
|
|
141
|
+
}, []);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Call useFingerprint() once in your root component.`;
|
|
145
|
+
case "javascript-spa":
|
|
146
|
+
return `// src/fingerprint.js
|
|
147
|
+
// Single-page app pattern: init once, identify() on each route change.
|
|
148
|
+
import { init } from 'fingerprint-platform-sdk';
|
|
149
|
+
|
|
150
|
+
export const fp = init({
|
|
151
|
+
${config}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// In your router's onNavigate hook:
|
|
155
|
+
// fp.identify({ event: 'route:' + path });`;
|
|
156
|
+
case "javascript":
|
|
157
|
+
default:
|
|
158
|
+
return `import { init } from 'fingerprint-platform-sdk';
|
|
159
|
+
|
|
160
|
+
const fp = init({
|
|
161
|
+
${config}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
fp.identify({ event: 'pageview' }).then((r) => {
|
|
165
|
+
if (r.visitorId) console.log(r.eventId, r.visitorId, r.suspectScore);
|
|
166
|
+
else console.log('event queued:', r.eventId);
|
|
167
|
+
});`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function cdnPlacementComment(framework) {
|
|
171
|
+
switch (framework) {
|
|
172
|
+
case "nextjs":
|
|
173
|
+
return `<!-- Next.js: prefer next/script with strategy="afterInteractive" in app/layout.tsx
|
|
174
|
+
rather than a raw <script>. The agent attaches to window.FP on first paint. -->`;
|
|
175
|
+
case "react":
|
|
176
|
+
return `<!-- React: drop <script> in public/index.html (CRA / Vite-React).
|
|
177
|
+
Access window.FP from any component after first paint. -->`;
|
|
178
|
+
case "vue":
|
|
179
|
+
return `<!-- Vue.js: place <script> in public/index.html.
|
|
180
|
+
Read window.FP inside onMounted / mounted hooks. -->`;
|
|
181
|
+
case "angular":
|
|
182
|
+
return `<!-- Angular: add <script> to src/index.html.
|
|
183
|
+
Wrap window.FP in an Injectable service for DI ergonomics. -->`;
|
|
184
|
+
case "svelte":
|
|
185
|
+
return `<!-- Svelte / SvelteKit: add <script> to app.html (SvelteKit) or
|
|
186
|
+
public/index.html. Use onMount() to read window.FP. -->`;
|
|
187
|
+
case "preact":
|
|
188
|
+
return `<!-- Preact: drop <script> in your root index.html.
|
|
189
|
+
Read window.FP from any component after mount. -->`;
|
|
190
|
+
case "javascript-spa":
|
|
191
|
+
return `<!-- SPA: include once in your shell HTML. Call fp.identify()
|
|
192
|
+
from your router's per-route hook to track navigation. -->`;
|
|
193
|
+
case "javascript":
|
|
194
|
+
default:
|
|
195
|
+
return `<!-- Vanilla JS: paste anywhere in <body>. The SDK fires
|
|
196
|
+
identify() on its own once init() resolves. -->`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/tools/analyze.ts
|
|
201
|
+
import { z } from "zod";
|
|
202
|
+
|
|
203
|
+
// src/util/detect-framework.ts
|
|
204
|
+
import { existsSync, readFileSync } from "fs";
|
|
205
|
+
import { resolve } from "path";
|
|
206
|
+
function detectFramework(cwd) {
|
|
207
|
+
const pkgJson = readPackageJson(cwd);
|
|
208
|
+
const deps = collectDeps(pkgJson);
|
|
209
|
+
const sdkInstalled = "fingerprint-platform-sdk" in deps;
|
|
210
|
+
if ("next" in deps) {
|
|
211
|
+
return scan("nextjs", cwd, sdkInstalled, "Detected `next` dependency in package.json.");
|
|
212
|
+
}
|
|
213
|
+
if ("nuxt" in deps) {
|
|
214
|
+
return scan("vue", cwd, sdkInstalled, "Detected `nuxt` \u2014 using the Vue.js plugin pattern.");
|
|
215
|
+
}
|
|
216
|
+
if ("@angular/core" in deps) {
|
|
217
|
+
return scan("angular", cwd, sdkInstalled, "Detected `@angular/core` dependency.");
|
|
218
|
+
}
|
|
219
|
+
if ("@sveltejs/kit" in deps || "svelte" in deps) {
|
|
220
|
+
return scan("svelte", cwd, sdkInstalled, "Detected Svelte (or SvelteKit).");
|
|
221
|
+
}
|
|
222
|
+
if ("preact" in deps) {
|
|
223
|
+
return scan("preact", cwd, sdkInstalled, "Detected `preact` dependency.");
|
|
224
|
+
}
|
|
225
|
+
if ("vue" in deps) {
|
|
226
|
+
return scan("vue", cwd, sdkInstalled, "Detected `vue` dependency.");
|
|
227
|
+
}
|
|
228
|
+
if ("react" in deps || "react-dom" in deps) {
|
|
229
|
+
return scan("react", cwd, sdkInstalled, "Detected `react` dependency.");
|
|
230
|
+
}
|
|
231
|
+
if (existsSync(resolve(cwd, "next.config.js")) || existsSync(resolve(cwd, "next.config.ts"))) {
|
|
232
|
+
return scan("nextjs", cwd, sdkInstalled, "Detected `next.config.*` without explicit dep.");
|
|
233
|
+
}
|
|
234
|
+
if (existsSync(resolve(cwd, "angular.json"))) {
|
|
235
|
+
return scan("angular", cwd, sdkInstalled, "Detected `angular.json`.");
|
|
236
|
+
}
|
|
237
|
+
if (existsSync(resolve(cwd, "svelte.config.js")) || existsSync(resolve(cwd, "svelte.config.ts"))) {
|
|
238
|
+
return scan("svelte", cwd, sdkInstalled, "Detected `svelte.config.*`.");
|
|
239
|
+
}
|
|
240
|
+
if ("react-router" in deps || "vue-router" in deps || "svelte-routing" in deps) {
|
|
241
|
+
return scan(
|
|
242
|
+
"javascript-spa",
|
|
243
|
+
cwd,
|
|
244
|
+
sdkInstalled,
|
|
245
|
+
"Detected a router library without a framework lib."
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return scan("javascript", cwd, sdkInstalled, "No framework signal \u2014 falling back to vanilla JS.");
|
|
249
|
+
}
|
|
250
|
+
function readPackageJson(cwd) {
|
|
251
|
+
const p = resolve(cwd, "package.json");
|
|
252
|
+
if (!existsSync(p)) return {};
|
|
253
|
+
try {
|
|
254
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
255
|
+
} catch {
|
|
256
|
+
return {};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function collectDeps(pkg) {
|
|
260
|
+
return {
|
|
261
|
+
...pkg.dependencies,
|
|
262
|
+
...pkg.devDependencies,
|
|
263
|
+
...pkg.peerDependencies
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function scan(framework, cwd, hasSDK, rationale) {
|
|
267
|
+
return {
|
|
268
|
+
framework,
|
|
269
|
+
entryFile: locateEntryFile(framework, cwd),
|
|
270
|
+
hasSDK,
|
|
271
|
+
rationale
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function locateEntryFile(framework, cwd) {
|
|
275
|
+
const candidates = {
|
|
276
|
+
nextjs: ["app/layout.tsx", "app/layout.jsx", "src/app/layout.tsx", "pages/_app.tsx"],
|
|
277
|
+
react: ["src/main.tsx", "src/main.jsx", "src/index.tsx", "src/index.jsx", "src/App.tsx"],
|
|
278
|
+
vue: ["src/main.ts", "src/main.js"],
|
|
279
|
+
angular: ["src/main.ts", "src/app/app.module.ts"],
|
|
280
|
+
svelte: ["src/routes/+layout.svelte", "src/main.ts", "src/main.js"],
|
|
281
|
+
preact: ["src/main.tsx", "src/index.tsx", "src/index.jsx"],
|
|
282
|
+
"javascript-spa": ["src/main.js", "src/main.ts", "src/index.js"],
|
|
283
|
+
javascript: ["index.html", "src/index.html", "public/index.html"]
|
|
284
|
+
};
|
|
285
|
+
for (const rel of candidates[framework]) {
|
|
286
|
+
const abs = resolve(cwd, rel);
|
|
287
|
+
if (existsSync(abs)) return abs;
|
|
288
|
+
}
|
|
289
|
+
return void 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// src/util/detect-pm.ts
|
|
293
|
+
import { existsSync as existsSync2 } from "fs";
|
|
294
|
+
import { resolve as resolve2 } from "path";
|
|
295
|
+
function detectPackageManager(cwd) {
|
|
296
|
+
if (existsSync2(resolve2(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
297
|
+
if (existsSync2(resolve2(cwd, "yarn.lock"))) return "yarn";
|
|
298
|
+
if (existsSync2(resolve2(cwd, "bun.lockb")) || existsSync2(resolve2(cwd, "bun.lock"))) return "bun";
|
|
299
|
+
return "npm";
|
|
300
|
+
}
|
|
301
|
+
function renderInstallCommand(pm, pkg) {
|
|
302
|
+
switch (pm) {
|
|
303
|
+
case "pnpm":
|
|
304
|
+
return `pnpm add ${pkg}`;
|
|
305
|
+
case "yarn":
|
|
306
|
+
return `yarn add ${pkg}`;
|
|
307
|
+
case "bun":
|
|
308
|
+
return `bun add ${pkg}`;
|
|
309
|
+
case "npm":
|
|
310
|
+
default:
|
|
311
|
+
return `npm install ${pkg}`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/tools/analyze.ts
|
|
316
|
+
var analyzeInputSchema = {
|
|
317
|
+
cwd: z.string().describe(
|
|
318
|
+
"Absolute path to the user project root. The AI assistant should pass its own current working directory or the project root it has been browsing."
|
|
319
|
+
)
|
|
320
|
+
};
|
|
321
|
+
function analyzeProject(input) {
|
|
322
|
+
const cwd = resolve3(input.cwd);
|
|
323
|
+
const scan2 = detectFramework(cwd);
|
|
324
|
+
const pm = detectPackageManager(cwd);
|
|
325
|
+
const nextSteps = [];
|
|
326
|
+
if (!scan2.hasSDK) {
|
|
327
|
+
nextSteps.push(`Call \`install_sdk\` with cwd="${cwd}" to install fingerprint-platform-sdk.`);
|
|
328
|
+
}
|
|
329
|
+
if (scan2.entryFile) {
|
|
330
|
+
nextSteps.push(
|
|
331
|
+
`Call \`wire_init\` with cwd="${cwd}", framework="${scan2.framework}", entryFile="${scan2.entryFile}" to insert the init snippet.`
|
|
332
|
+
);
|
|
333
|
+
} else {
|
|
334
|
+
nextSteps.push(
|
|
335
|
+
`Couldn't auto-locate an entry file for ${scan2.framework}. Ask the user where they want \`fp.identify()\` to fire on app boot, then pass that path to \`wire_init\`.`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
nextSteps.push(
|
|
339
|
+
`Call \`set_collector_url\` once you know the user's collector URL \u2014 typically https://<their-domain>/fpjs proxied through a Cloudflare Worker.`
|
|
340
|
+
);
|
|
341
|
+
const details = `**Project scan**
|
|
342
|
+
|
|
343
|
+
- Framework: \`${scan2.framework}\` (${frameworkLabel(scan2.framework)})
|
|
344
|
+
- Package manager: \`${pm}\` (detected from lockfile)
|
|
345
|
+
- SDK already installed: ${scan2.hasSDK ? "yes" : "no"}
|
|
346
|
+
- Entry file: ${scan2.entryFile ? `\`${scan2.entryFile}\`` : "_not auto-detected_"}
|
|
347
|
+
|
|
348
|
+
_${scan2.rationale}_`;
|
|
349
|
+
return {
|
|
350
|
+
summary: `Detected ${frameworkLabel(scan2.framework)} project (${pm}, SDK ${scan2.hasSDK ? "installed" : "missing"}).`,
|
|
351
|
+
details,
|
|
352
|
+
nextSteps,
|
|
353
|
+
meta: {
|
|
354
|
+
cwd,
|
|
355
|
+
framework: scan2.framework,
|
|
356
|
+
packageManager: pm,
|
|
357
|
+
entryFile: scan2.entryFile ?? null,
|
|
358
|
+
hasSDK: scan2.hasSDK
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/tools/configure.ts
|
|
364
|
+
import { resolve as resolve4 } from "path";
|
|
365
|
+
import { z as z2 } from "zod";
|
|
366
|
+
|
|
367
|
+
// src/util/diff.ts
|
|
368
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
369
|
+
import { relative } from "path";
|
|
370
|
+
import { createPatch } from "diff";
|
|
371
|
+
function makeUnifiedDiff(opts) {
|
|
372
|
+
const oldContent = existsSync3(opts.filePath) ? readFileSync2(opts.filePath, "utf8") : "";
|
|
373
|
+
const rel = relative(opts.cwd, opts.filePath).split("\\").join("/");
|
|
374
|
+
return createPatch(rel, oldContent, opts.newContent, "", "", { context: 3 });
|
|
375
|
+
}
|
|
376
|
+
function makeNewFileDiff(opts) {
|
|
377
|
+
const rel = relative(opts.cwd, opts.filePath).split("\\").join("/");
|
|
378
|
+
return createPatch(rel, "", opts.content, "", "", { context: 3 });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/tools/configure.ts
|
|
382
|
+
var setCollectorUrlInputSchema = {
|
|
383
|
+
projectKey: z2.string().describe("Project's public API key."),
|
|
384
|
+
collectorUrl: z2.string().url().describe(
|
|
385
|
+
"Same-origin collector URL \u2014 typically https://<their-domain>/fpjs (proxied by a Cloudflare Worker)."
|
|
386
|
+
)
|
|
387
|
+
};
|
|
388
|
+
function setCollectorUrl(input) {
|
|
389
|
+
let parsed;
|
|
390
|
+
try {
|
|
391
|
+
parsed = new URL(input.collectorUrl);
|
|
392
|
+
} catch {
|
|
393
|
+
return {
|
|
394
|
+
summary: "Invalid collector URL.",
|
|
395
|
+
details: `\`${input.collectorUrl}\` is not a valid URL. Pass a full https URL like \`https://yoursite.com/fpjs\`.`,
|
|
396
|
+
meta: { error: "invalid_url" }
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (parsed.protocol !== "https:" && parsed.hostname !== "localhost") {
|
|
400
|
+
return {
|
|
401
|
+
summary: "Collector URL must be HTTPS.",
|
|
402
|
+
details: `The browser SDK uses X25519 sealing which requires a secure context (HTTPS or localhost). \`${parsed.protocol}//${parsed.hostname}\` would fail at runtime \u2014 please serve via HTTPS in production.`,
|
|
403
|
+
meta: { error: "http_in_production" }
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const originForAllowedHosts = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
|
|
407
|
+
return {
|
|
408
|
+
summary: `Use \`${input.collectorUrl}\` as the collector URL.`,
|
|
409
|
+
details: `**1) Add to your environment** (e.g. \`.env.local\`):
|
|
410
|
+
|
|
411
|
+
\`\`\`bash
|
|
412
|
+
FP_PROJECT_KEY=${input.projectKey}
|
|
413
|
+
FP_COLLECTOR_URL=${input.collectorUrl}
|
|
414
|
+
\`\`\`
|
|
415
|
+
|
|
416
|
+
**2) Add origin to dashboard allowedHosts.** Open the Fingerprint dashboard \u2192 Security \u2192 Allowed hosts and add:
|
|
417
|
+
|
|
418
|
+
\`${originForAllowedHosts}\`
|
|
419
|
+
|
|
420
|
+
Without this entry the collector rejects requests from your site with a 401 \`origin_not_allowed\`. (We can't mutate the dashboard from the MCP server in v1; this step is manual.)`,
|
|
421
|
+
nextSteps: [
|
|
422
|
+
`Add the two env vars above to \`.env.local\` (or your secret manager).`,
|
|
423
|
+
`Open the dashboard's Security page and add \`${originForAllowedHosts}\` to allowedHosts.`,
|
|
424
|
+
`Call \`generate_cloudflare_worker\` if you want a same-origin proxy on /fpjs/*.`
|
|
425
|
+
],
|
|
426
|
+
artifact: {
|
|
427
|
+
kind: "env",
|
|
428
|
+
content: {
|
|
429
|
+
FP_PROJECT_KEY: input.projectKey,
|
|
430
|
+
FP_COLLECTOR_URL: input.collectorUrl
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
meta: { origin: originForAllowedHosts, collectorUrl: input.collectorUrl }
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
var generateCloudflareWorkerInputSchema = {
|
|
437
|
+
cwd: z2.string().describe("Absolute path to the user project root \u2014 for the diff header."),
|
|
438
|
+
collectorHost: z2.string().describe(
|
|
439
|
+
"Hostname of the Fingerprint collector to proxy to, without scheme. E.g. `collector.yoursite.com` or `89.124.91.162`."
|
|
440
|
+
),
|
|
441
|
+
customDomain: z2.string().describe(
|
|
442
|
+
"Your site's domain, e.g. `yoursite.com`. The Worker routes `<customDomain>/fpjs/*`."
|
|
443
|
+
)
|
|
444
|
+
};
|
|
445
|
+
function generateCloudflareWorker(input) {
|
|
446
|
+
const cwd = resolve4(input.cwd);
|
|
447
|
+
const workerPath = resolve4(cwd, "cloudflare/worker.ts");
|
|
448
|
+
const wranglerPath = resolve4(cwd, "cloudflare/wrangler.toml");
|
|
449
|
+
const host = input.collectorHost.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
450
|
+
const domain = input.customDomain.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
451
|
+
const workerScript = cloudflareWorkerTemplate(host);
|
|
452
|
+
const wranglerToml = wranglerTomlTemplate(domain);
|
|
453
|
+
const workerDiff = makeNewFileDiff({ cwd, filePath: workerPath, content: workerScript });
|
|
454
|
+
const wranglerDiff = makeNewFileDiff({ cwd, filePath: wranglerPath, content: wranglerToml });
|
|
455
|
+
return {
|
|
456
|
+
summary: `Cloudflare Worker template for \`${domain}/fpjs/*\` \u2192 \`${host}\`.`,
|
|
457
|
+
details: `Two files to write into the user's repo:
|
|
458
|
+
|
|
459
|
+
**\`cloudflare/worker.ts\`** \u2014 the Worker script body.
|
|
460
|
+
**\`cloudflare/wrangler.toml\`** \u2014 Wrangler deploy config (route + zone).
|
|
461
|
+
|
|
462
|
+
Both diffs are pasted below. Apply with your edit tool, then run
|
|
463
|
+
|
|
464
|
+
\`\`\`bash
|
|
465
|
+
cd cloudflare && wrangler deploy
|
|
466
|
+
\`\`\`
|
|
467
|
+
|
|
468
|
+
from the project root. After deploy, the Worker is live on the route
|
|
469
|
+
pattern \`${domain}/fpjs/*\`. The SDK then loads same-origin: cookies
|
|
470
|
+
stay first-party, ad-blockers can't pattern-match a cross-origin path.
|
|
471
|
+
|
|
472
|
+
--- ${workerPath} ---
|
|
473
|
+
|
|
474
|
+
\`\`\`diff
|
|
475
|
+
${workerDiff}
|
|
476
|
+
\`\`\`
|
|
477
|
+
|
|
478
|
+
--- ${wranglerPath} ---
|
|
479
|
+
|
|
480
|
+
\`\`\`diff
|
|
481
|
+
${wranglerDiff}
|
|
482
|
+
\`\`\``,
|
|
483
|
+
nextSteps: [
|
|
484
|
+
`Apply both diffs to create the cloudflare/ directory.`,
|
|
485
|
+
`Install Wrangler: \`npm i -g wrangler\` (or use \`npx wrangler\`).`,
|
|
486
|
+
`Authenticate: \`wrangler login\`.`,
|
|
487
|
+
`Deploy: \`cd cloudflare && wrangler deploy\`.`,
|
|
488
|
+
`Update \`FP_COLLECTOR_URL\` env var to \`https://${domain}/fpjs\`.`,
|
|
489
|
+
`Add \`https://${domain}\` to dashboard allowedHosts via the Security page.`
|
|
490
|
+
],
|
|
491
|
+
// Single diff payload that concatenates both files — git apply
|
|
492
|
+
// accepts multi-file unified diffs.
|
|
493
|
+
artifact: { kind: "diff", content: `${workerDiff}
|
|
494
|
+
${wranglerDiff}` },
|
|
495
|
+
meta: {
|
|
496
|
+
workerPath,
|
|
497
|
+
wranglerPath,
|
|
498
|
+
route: `${domain}/fpjs/*`,
|
|
499
|
+
collectorHost: host
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
var generateDnsFallbackInputSchema = {
|
|
504
|
+
subdomain: z2.string().describe("Subdomain to point at the collector, e.g. `fp.yoursite.com`. Must be a FQDN."),
|
|
505
|
+
collectorHost: z2.string().describe("Hostname (or IP) of the Fingerprint collector.")
|
|
506
|
+
};
|
|
507
|
+
function generateDnsFallback(input) {
|
|
508
|
+
const host = input.collectorHost.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
509
|
+
const apex = input.subdomain.split(".").slice(1).join(".");
|
|
510
|
+
return {
|
|
511
|
+
summary: `DNS CNAME \`${input.subdomain}\` \u2192 \`${host}\`.`,
|
|
512
|
+
details: `Add the following DNS record in your registrar's zone editor for \`${apex}\`:
|
|
513
|
+
|
|
514
|
+
\`\`\`text
|
|
515
|
+
Type: CNAME
|
|
516
|
+
Name: ${input.subdomain.replace(`.${apex}`, "")}
|
|
517
|
+
Value: ${host}
|
|
518
|
+
TTL: 300 (5 min \u2014 bump after verification)
|
|
519
|
+
\`\`\`
|
|
520
|
+
|
|
521
|
+
Once propagated, set:
|
|
522
|
+
|
|
523
|
+
\`\`\`bash
|
|
524
|
+
FP_COLLECTOR_URL=https://${input.subdomain}
|
|
525
|
+
\`\`\`
|
|
526
|
+
|
|
527
|
+
Then add \`https://${input.subdomain}\` to the dashboard's allowedHosts on the Security page.
|
|
528
|
+
|
|
529
|
+
_Trade-off vs the Cloudflare Worker route: a dedicated subdomain is **easier for ad-block filter rules to catch** than a same-origin path like \`yoursite.com/fpjs/*\`. Use the Worker if you can; this is the fallback when Cloudflare isn't on the table._`,
|
|
530
|
+
nextSteps: [
|
|
531
|
+
`Create the CNAME record in your DNS provider.`,
|
|
532
|
+
`Wait for propagation (\`dig ${input.subdomain}\` should return the CNAME).`,
|
|
533
|
+
`Update \`FP_COLLECTOR_URL\` and dashboard allowedHosts.`
|
|
534
|
+
],
|
|
535
|
+
meta: { subdomain: input.subdomain, collectorHost: host, apex }
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function cloudflareWorkerTemplate(collectorHost) {
|
|
539
|
+
return `/**
|
|
540
|
+
* Fingerprint Platform same-origin proxy.
|
|
541
|
+
*
|
|
542
|
+
* Generated by fingerprint-platform-mcp.
|
|
543
|
+
*
|
|
544
|
+
* Route pattern: <yoursite>/fpjs/*
|
|
545
|
+
*
|
|
546
|
+
* Proxies BOTH the SDK bundle (served on /fpjs/agent.js) AND every
|
|
547
|
+
* collector endpoint (/fpjs/v1/ingest, /fpjs/v1/result/<id>,
|
|
548
|
+
* /fpjs/v1/pk, /fpjs/v1/comparison). Cookies stay first-party,
|
|
549
|
+
* ad-blockers cannot pattern-match a same-origin path.
|
|
550
|
+
*
|
|
551
|
+
* The integrator's app backend is NEVER in the fingerprint request
|
|
552
|
+
* path \u2014 that would couple uptime and add latency. The Worker
|
|
553
|
+
* fans out directly to ${collectorHost}.
|
|
554
|
+
*/
|
|
555
|
+
|
|
556
|
+
const COLLECTOR_ORIGIN = "https://${collectorHost}";
|
|
557
|
+
|
|
558
|
+
export default {
|
|
559
|
+
async fetch(req: Request): Promise<Response> {
|
|
560
|
+
const url = new URL(req.url);
|
|
561
|
+
|
|
562
|
+
// SDK bundle.
|
|
563
|
+
if (url.pathname === "/fpjs/agent.js") {
|
|
564
|
+
const upstream = new URL("/v1/agent.js", COLLECTOR_ORIGIN);
|
|
565
|
+
return forward(req, upstream);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Collector API surface under /fpjs/v1/*.
|
|
569
|
+
if (url.pathname.startsWith("/fpjs/v1/")) {
|
|
570
|
+
const upstream = new URL(url.pathname.replace(/^\\/fpjs/, ""), COLLECTOR_ORIGIN);
|
|
571
|
+
upstream.search = url.search;
|
|
572
|
+
return forward(req, upstream);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Unknown path under /fpjs/ \u2014 404 cleanly so misconfigured route
|
|
576
|
+
// patterns don't accidentally proxy the user's whole site.
|
|
577
|
+
return new Response("not found", { status: 404 });
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
async function forward(req: Request, upstream: URL): Promise<Response> {
|
|
582
|
+
const headers = new Headers(req.headers);
|
|
583
|
+
// Preserve the original client IP for the collector's IP-class
|
|
584
|
+
// enrichment \u2014 Cloudflare exposes it via cf-connecting-ip.
|
|
585
|
+
const clientIp = headers.get("cf-connecting-ip") ?? headers.get("x-forwarded-for");
|
|
586
|
+
if (clientIp) headers.set("x-forwarded-for", clientIp);
|
|
587
|
+
// Strip the Host header so the upstream sees its own hostname.
|
|
588
|
+
headers.delete("host");
|
|
589
|
+
|
|
590
|
+
const init: RequestInit = {
|
|
591
|
+
method: req.method,
|
|
592
|
+
headers,
|
|
593
|
+
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
|
|
594
|
+
redirect: "manual",
|
|
595
|
+
};
|
|
596
|
+
return fetch(upstream.toString(), init);
|
|
597
|
+
}
|
|
598
|
+
`;
|
|
599
|
+
}
|
|
600
|
+
function wranglerTomlTemplate(customDomain) {
|
|
601
|
+
return `name = "fingerprint-proxy"
|
|
602
|
+
main = "worker.ts"
|
|
603
|
+
compatibility_date = "2025-01-01"
|
|
604
|
+
|
|
605
|
+
# Attach the Worker to your domain. Replace the zone_name if it
|
|
606
|
+
# differs from the domain. Wrangler will prompt for the zone on
|
|
607
|
+
# first deploy if you remove the zone_name line.
|
|
608
|
+
routes = [
|
|
609
|
+
{ pattern = "${customDomain}/fpjs/*", zone_name = "${customDomain}" },
|
|
610
|
+
]
|
|
611
|
+
`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// src/tools/explain.ts
|
|
615
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
616
|
+
import { dirname, resolve as resolve5 } from "path";
|
|
617
|
+
import { fileURLToPath } from "url";
|
|
618
|
+
import { z as z3 } from "zod";
|
|
619
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
620
|
+
var TOPIC_FILES = {
|
|
621
|
+
"origin-allowlist": "origin-allowlist.md",
|
|
622
|
+
"cloudflare-proxy": "cloudflare-proxy.md",
|
|
623
|
+
"dns-cname": "dns-cname.md",
|
|
624
|
+
"api-keys": "api-keys.md",
|
|
625
|
+
"events-vs-identify": "events-vs-identify.md",
|
|
626
|
+
"sync-vs-async": "sync-vs-async.md"
|
|
627
|
+
};
|
|
628
|
+
var explainInputSchema = {
|
|
629
|
+
topic: z3.enum([
|
|
630
|
+
"origin-allowlist",
|
|
631
|
+
"cloudflare-proxy",
|
|
632
|
+
"dns-cname",
|
|
633
|
+
"api-keys",
|
|
634
|
+
"events-vs-identify",
|
|
635
|
+
"sync-vs-async"
|
|
636
|
+
]).describe(
|
|
637
|
+
"Topic id. The AI assistant picks one based on the user question. Each topic returns a focused markdown brief \u2014 pasting raw docs into the conversation tends to drown out the actionable bits."
|
|
638
|
+
)
|
|
639
|
+
};
|
|
640
|
+
function explainTopic(input) {
|
|
641
|
+
const filename = TOPIC_FILES[input.topic];
|
|
642
|
+
if (!filename) {
|
|
643
|
+
return {
|
|
644
|
+
summary: `Unknown topic "${input.topic}".`,
|
|
645
|
+
details: `Known topics: ${Object.keys(TOPIC_FILES).join(", ")}.`,
|
|
646
|
+
meta: { error: "unknown_topic" }
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
const candidates = [
|
|
650
|
+
resolve5(__dirname, "..", "docs", filename),
|
|
651
|
+
resolve5(__dirname, "docs", filename),
|
|
652
|
+
resolve5(__dirname, "..", "..", "src", "docs", filename)
|
|
653
|
+
];
|
|
654
|
+
for (const path of candidates) {
|
|
655
|
+
try {
|
|
656
|
+
const content = readFileSync3(path, "utf8");
|
|
657
|
+
return {
|
|
658
|
+
summary: `Returned docs/${filename} (${content.length} chars).`,
|
|
659
|
+
details: content,
|
|
660
|
+
artifact: { kind: "markdown", content },
|
|
661
|
+
meta: { topic: input.topic, path }
|
|
662
|
+
};
|
|
663
|
+
} catch {
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
summary: `docs/${filename} not found at runtime.`,
|
|
668
|
+
details: `Looked at: ${candidates.join(", ")}. This is a build issue \u2014 the tsup config should be copying src/docs/ into dist/docs/. Re-run \`pnpm build\` and try again.`,
|
|
669
|
+
meta: { error: "doc_missing", topic: input.topic }
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/tools/install.ts
|
|
674
|
+
import { resolve as resolve6 } from "path";
|
|
675
|
+
import { z as z4 } from "zod";
|
|
676
|
+
var installInputSchema = {
|
|
677
|
+
cwd: z4.string().describe("Absolute path to the user project root."),
|
|
678
|
+
packageManager: z4.enum(["pnpm", "yarn", "bun", "npm"]).optional().describe(
|
|
679
|
+
"Override the auto-detected package manager. Most callers should let analyze_project decide."
|
|
680
|
+
),
|
|
681
|
+
source: z4.enum(["npm", "cdn"]).default("npm").describe(
|
|
682
|
+
"Install source. `npm` is the default (recommended). `cdn` returns the <script> tag snippet instead \u2014 useful when the user explicitly doesn't want to add an npm dep."
|
|
683
|
+
)
|
|
684
|
+
};
|
|
685
|
+
function installSdk(input) {
|
|
686
|
+
const cwd = resolve6(input.cwd);
|
|
687
|
+
const source = input.source ?? "npm";
|
|
688
|
+
if (source === "cdn") {
|
|
689
|
+
return {
|
|
690
|
+
summary: "CDN install \u2014 no package manager command needed.",
|
|
691
|
+
details: 'Add the following `<script>` tag to your HTML shell (e.g. `public/index.html`, `app.html`, or the framework-specific entry document). Once it loads, `window.FP.init({...})` is available.\n\n```html\n<script src="/fpjs/agent.js" async></script>\n```\n\n_The recommended `/fpjs/agent.js` path is served by your Cloudflare Worker proxy (see `generate_cloudflare_worker`). The unpkg URL works for local dev but is on every ad-block list \u2014 do not ship it to production._',
|
|
692
|
+
nextSteps: [`Call \`wire_init\` to insert the init() call into the right entry file.`],
|
|
693
|
+
artifact: {
|
|
694
|
+
kind: "markdown",
|
|
695
|
+
content: '<script src="/fpjs/agent.js" async></script>'
|
|
696
|
+
},
|
|
697
|
+
meta: { source: "cdn", cwd }
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
const pm = input.packageManager ?? detectPackageManager(cwd);
|
|
701
|
+
const cmd = renderInstallCommand(pm, "fingerprint-platform-sdk");
|
|
702
|
+
return {
|
|
703
|
+
summary: `Install via ${pm}: \`${cmd}\``,
|
|
704
|
+
details: `Run the following command in the project root (\`${cwd}\`):
|
|
705
|
+
|
|
706
|
+
\`\`\`bash
|
|
707
|
+
` + cmd + `
|
|
708
|
+
\`\`\`
|
|
709
|
+
|
|
710
|
+
_Detected package manager: ${pm}. If this is wrong, pass \`packageManager\` explicitly._`,
|
|
711
|
+
nextSteps: [
|
|
712
|
+
`Execute the command above via your bash tool.`,
|
|
713
|
+
`Then call \`wire_init\` to insert the SDK init() call into the right entry file.`
|
|
714
|
+
],
|
|
715
|
+
artifact: { kind: "command", content: cmd, cwd },
|
|
716
|
+
meta: { source: "npm", packageManager: pm, cwd, command: cmd }
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// src/tools/verify.ts
|
|
721
|
+
import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4, statSync } from "fs";
|
|
722
|
+
import { resolve as resolve7 } from "path";
|
|
723
|
+
import { z as z5 } from "zod";
|
|
724
|
+
var verifyInstallInputSchema = {
|
|
725
|
+
cwd: z5.string().describe("Absolute path to the user project root.")
|
|
726
|
+
};
|
|
727
|
+
function verifyInstall(input) {
|
|
728
|
+
const cwd = resolve7(input.cwd);
|
|
729
|
+
const scan2 = detectFramework(cwd);
|
|
730
|
+
const checks = [];
|
|
731
|
+
checks.push({
|
|
732
|
+
name: "fingerprint-platform-sdk in dependencies",
|
|
733
|
+
status: scan2.hasSDK ? "ok" : "fail",
|
|
734
|
+
hint: scan2.hasSDK ? `Detected via package.json.` : `Run \`install_sdk\` and apply its install command.`
|
|
735
|
+
});
|
|
736
|
+
const initCallFound = grepForInitCall(cwd);
|
|
737
|
+
checks.push({
|
|
738
|
+
name: "SDK `init(...)` call present in source tree",
|
|
739
|
+
status: initCallFound ? "ok" : "warn",
|
|
740
|
+
hint: initCallFound ? `Found a reference to \`init({...})\` or \`window.FP.init\` in the source.` : `Couldn't find an \`init({...})\` call. Either re-run \`wire_init\` to place the snippet, or confirm the user mounted the generated module.`
|
|
741
|
+
});
|
|
742
|
+
const envCheck = envVarStatus(cwd);
|
|
743
|
+
checks.push({
|
|
744
|
+
name: "FP_PROJECT_KEY + FP_COLLECTOR_URL configured",
|
|
745
|
+
status: envCheck.both ? "ok" : envCheck.either ? "warn" : "fail",
|
|
746
|
+
hint: envCheck.hint
|
|
747
|
+
});
|
|
748
|
+
const failCount = checks.filter((c) => c.status === "fail").length;
|
|
749
|
+
const warnCount = checks.filter((c) => c.status === "warn").length;
|
|
750
|
+
const summary = failCount > 0 ? `${failCount} check failed, ${warnCount} warning. See checklist.` : warnCount > 0 ? `Mostly good \u2014 ${warnCount} thing(s) to confirm manually.` : "All checks green. Send a test event and look for it on the dashboard.";
|
|
751
|
+
return {
|
|
752
|
+
summary,
|
|
753
|
+
details: `**Verification checklist**
|
|
754
|
+
|
|
755
|
+
` + checks.map((c) => `- ${badge(c.status)} **${c.name}** \u2014 ${c.hint}`).join("\n"),
|
|
756
|
+
nextSteps: failCount === 0 && warnCount === 0 ? [
|
|
757
|
+
`Trigger an event by reloading the page where the SDK is mounted.`,
|
|
758
|
+
`Open the dashboard \u2192 Identification view; the event should appear within ~2s.`
|
|
759
|
+
] : [`Resolve the failed / warned checks above and call \`verify_install\` again.`],
|
|
760
|
+
meta: { checks, framework: scan2.framework }
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
function badge(status) {
|
|
764
|
+
return status === "ok" ? "\u2713" : status === "warn" ? "\u26A0" : "\u2717";
|
|
765
|
+
}
|
|
766
|
+
function grepForInitCall(cwd) {
|
|
767
|
+
let count = 0;
|
|
768
|
+
const stack = [cwd];
|
|
769
|
+
const seen = /* @__PURE__ */ new Set();
|
|
770
|
+
while (stack.length > 0 && count < 5e3) {
|
|
771
|
+
const dir = stack.pop();
|
|
772
|
+
if (!dir || seen.has(dir)) continue;
|
|
773
|
+
seen.add(dir);
|
|
774
|
+
if (/[\\/](node_modules|dist|\.next|build|coverage|\.turbo)$/.test(dir)) continue;
|
|
775
|
+
let entries;
|
|
776
|
+
try {
|
|
777
|
+
entries = readdirSync(dir);
|
|
778
|
+
} catch {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
for (const name of entries) {
|
|
782
|
+
count++;
|
|
783
|
+
if (count >= 5e3) break;
|
|
784
|
+
const abs = resolve7(dir, name);
|
|
785
|
+
let stat;
|
|
786
|
+
try {
|
|
787
|
+
stat = statSync(abs);
|
|
788
|
+
} catch {
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
if (stat.isDirectory()) {
|
|
792
|
+
stack.push(abs);
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (!/\.(t|j)sx?$|\.svelte$|\.vue$|\.html$/.test(name)) continue;
|
|
796
|
+
try {
|
|
797
|
+
const raw = readFileSync4(abs, "utf8");
|
|
798
|
+
if (/\bFP\.init\(|\binit\(\s*\{\s*(collectorUrl|projectKey)/m.test(raw)) {
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
} catch {
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
function envVarStatus(cwd) {
|
|
808
|
+
const fromProcess = {
|
|
809
|
+
key: Boolean(process.env["FP_PROJECT_KEY"]),
|
|
810
|
+
url: Boolean(process.env["FP_COLLECTOR_URL"])
|
|
811
|
+
};
|
|
812
|
+
const dotenvFiles = [".env", ".env.local", ".env.development", ".env.production"];
|
|
813
|
+
const seenInDotenv = { key: false, url: false };
|
|
814
|
+
for (const name of dotenvFiles) {
|
|
815
|
+
const p = resolve7(cwd, name);
|
|
816
|
+
if (!existsSync4(p)) continue;
|
|
817
|
+
try {
|
|
818
|
+
const raw = readFileSync4(p, "utf8");
|
|
819
|
+
if (/^\s*FP_PROJECT_KEY\s*=/m.test(raw)) seenInDotenv.key = true;
|
|
820
|
+
if (/^\s*FP_COLLECTOR_URL\s*=/m.test(raw)) seenInDotenv.url = true;
|
|
821
|
+
} catch {
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const keyOk = fromProcess.key || seenInDotenv.key;
|
|
825
|
+
const urlOk = fromProcess.url || seenInDotenv.url;
|
|
826
|
+
const both = keyOk && urlOk;
|
|
827
|
+
const either = keyOk || urlOk;
|
|
828
|
+
const missing = [];
|
|
829
|
+
if (!keyOk) missing.push("FP_PROJECT_KEY");
|
|
830
|
+
if (!urlOk) missing.push("FP_COLLECTOR_URL");
|
|
831
|
+
return {
|
|
832
|
+
both,
|
|
833
|
+
either,
|
|
834
|
+
hint: both ? `Found both vars (in process.env or a .env file).` : `Missing: ${missing.join(", ")}. Add to \`.env.local\` via \`set_collector_url\`.`
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// src/tools/wire-init.ts
|
|
839
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
|
|
840
|
+
import { dirname as dirname2, resolve as resolve8 } from "path";
|
|
841
|
+
import { z as z6 } from "zod";
|
|
842
|
+
var wireInitInputSchema = {
|
|
843
|
+
cwd: z6.string().describe("Absolute path to the user project root."),
|
|
844
|
+
framework: z6.enum(["javascript", "javascript-spa", "nextjs", "react", "vue", "angular", "svelte", "preact"]).describe("Framework id from `analyze_project`."),
|
|
845
|
+
entryFile: z6.string().describe(
|
|
846
|
+
"Absolute path to the file that should host the SDK init call (from `analyze_project.meta.entryFile`)."
|
|
847
|
+
),
|
|
848
|
+
apiKey: z6.string().describe(
|
|
849
|
+
"Project's public API key. Substitute the value from the dashboard's API keys page or env.FP_PROJECT_KEY."
|
|
850
|
+
),
|
|
851
|
+
collectorUrl: z6.string().url().describe(
|
|
852
|
+
"Same-origin collector URL \u2014 typically https://<their-domain>/fpjs (proxied by a Cloudflare Worker)."
|
|
853
|
+
),
|
|
854
|
+
source: z6.enum(["npm", "cdn"]).default("npm").describe("Match whatever `install_sdk` ran with. Defaults to `npm`.")
|
|
855
|
+
};
|
|
856
|
+
function wireInit(input) {
|
|
857
|
+
const cwd = resolve8(input.cwd);
|
|
858
|
+
const entryFile = resolve8(input.entryFile);
|
|
859
|
+
const source = input.source ?? "npm";
|
|
860
|
+
const snippet = initSnippet({
|
|
861
|
+
framework: input.framework,
|
|
862
|
+
source,
|
|
863
|
+
apiKey: input.apiKey,
|
|
864
|
+
collectorUrl: input.collectorUrl
|
|
865
|
+
});
|
|
866
|
+
if (source === "cdn") {
|
|
867
|
+
if (!existsSync5(entryFile)) {
|
|
868
|
+
return missingEntryFile(entryFile, input.framework);
|
|
869
|
+
}
|
|
870
|
+
const current = readFileSync5(entryFile, "utf8");
|
|
871
|
+
const next = appendBeforeBodyClose(current, snippet);
|
|
872
|
+
const diff2 = makeUnifiedDiff({ cwd, filePath: entryFile, newContent: next });
|
|
873
|
+
return successDiff(diff2, entryFile, input.framework, source, [
|
|
874
|
+
`Apply the diff to \`${entryFile}\` via your edit tool.`,
|
|
875
|
+
`Reload the page and confirm \`window.FP.init\` is defined in DevTools.`
|
|
876
|
+
]);
|
|
877
|
+
}
|
|
878
|
+
const targetFile = perFrameworkTargetFile(input.framework, cwd);
|
|
879
|
+
const diff = makeNewFileDiff({ cwd, filePath: targetFile, content: snippet });
|
|
880
|
+
const mountHint = perFrameworkMountHint(input.framework);
|
|
881
|
+
return successDiff(diff, targetFile, input.framework, source, [
|
|
882
|
+
`Apply the diff to create \`${targetFile}\` via your edit tool.`,
|
|
883
|
+
mountHint,
|
|
884
|
+
`Run the app and confirm the SDK boots \u2014 open DevTools, you should see the eventId / visitorId logged.`
|
|
885
|
+
]);
|
|
886
|
+
}
|
|
887
|
+
function successDiff(diff, filePath, framework, source, nextSteps) {
|
|
888
|
+
return {
|
|
889
|
+
summary: `Wrote ${source} init for ${frameworkLabel(framework)} into \`${filePath}\`.`,
|
|
890
|
+
details: `Apply the unified diff below with \`git apply\` (or your AI assistant's edit tool). The patch is against \`${filePath}\` and is generated from the same \`initSnippet()\` helper the dashboard's Get Started page uses \u2014 so what you see here matches the install snippet shown there byte-for-byte.
|
|
891
|
+
|
|
892
|
+
\`\`\`diff
|
|
893
|
+
` + diff + "\n```",
|
|
894
|
+
nextSteps,
|
|
895
|
+
artifact: { kind: "diff", content: diff },
|
|
896
|
+
meta: { framework, source, filePath }
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
function missingEntryFile(entryFile, framework) {
|
|
900
|
+
return {
|
|
901
|
+
summary: `Entry file \`${entryFile}\` not found.`,
|
|
902
|
+
details: `The path we were given doesn't exist. Re-run \`analyze_project\` to refresh the detection, or ask the user where their ${frameworkLabel(framework)} entry document lives (typically \`public/index.html\`, \`app.html\`, or the framework's root template).`,
|
|
903
|
+
nextSteps: [`Call \`analyze_project\` again to re-detect, or accept the user's manual path.`],
|
|
904
|
+
meta: { error: "entry_not_found", entryFile }
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
function perFrameworkTargetFile(framework, cwd) {
|
|
908
|
+
const map = {
|
|
909
|
+
nextjs: "app/_components/FpClient.tsx",
|
|
910
|
+
react: "src/hooks/useFingerprint.ts",
|
|
911
|
+
vue: "src/plugins/fingerprint.ts",
|
|
912
|
+
angular: "src/app/fingerprint.service.ts",
|
|
913
|
+
svelte: "src/lib/fingerprint.ts",
|
|
914
|
+
preact: "src/hooks/useFingerprint.ts",
|
|
915
|
+
"javascript-spa": "src/fingerprint.ts",
|
|
916
|
+
javascript: "src/fingerprint.ts"
|
|
917
|
+
};
|
|
918
|
+
return resolve8(cwd, map[framework]);
|
|
919
|
+
}
|
|
920
|
+
function perFrameworkMountHint(framework) {
|
|
921
|
+
switch (framework) {
|
|
922
|
+
case "nextjs":
|
|
923
|
+
return "Mount `<FpClient />` once in `app/layout.tsx` so it boots on every route.";
|
|
924
|
+
case "react":
|
|
925
|
+
case "preact":
|
|
926
|
+
return "Call `useFingerprint()` once in your root `App` component.";
|
|
927
|
+
case "vue":
|
|
928
|
+
return 'In `main.ts`: `import fpPlugin from "./plugins/fingerprint"; app.use(fpPlugin);`. Then from any component call `this.$fp.identify({ event: "pageview" })`.';
|
|
929
|
+
case "angular":
|
|
930
|
+
return 'Inject `FingerprintService` in any component and call `this.fp.fp.identify({ event: "pageview" })`.';
|
|
931
|
+
case "svelte":
|
|
932
|
+
return 'In a `+page.svelte`: `import { fp } from "$lib/fingerprint"; onMount(() => fp.identify({ event: "pageview" }));`.';
|
|
933
|
+
case "javascript-spa":
|
|
934
|
+
return "Hook your router's `onNavigate` to call `fp.identify({ event: 'route:' + path })`.";
|
|
935
|
+
case "javascript":
|
|
936
|
+
default:
|
|
937
|
+
return "Import the module from your entry point so the `fp.identify()` call fires on load.";
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
function appendBeforeBodyClose(html, snippet) {
|
|
941
|
+
const lower = html.toLowerCase();
|
|
942
|
+
const idx = lower.lastIndexOf("</body>");
|
|
943
|
+
if (idx === -1) {
|
|
944
|
+
const trailingNewline = html.endsWith("\n") ? "" : "\n";
|
|
945
|
+
return `${html}${trailingNewline}${snippet}
|
|
946
|
+
`;
|
|
947
|
+
}
|
|
948
|
+
const before = html.slice(0, idx);
|
|
949
|
+
const after = html.slice(idx);
|
|
950
|
+
return `${before}${snippet}
|
|
951
|
+
${after}`;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// src/server.ts
|
|
955
|
+
function buildServer() {
|
|
956
|
+
const server = new McpServer(
|
|
957
|
+
{
|
|
958
|
+
name: "fingerprint-platform-mcp",
|
|
959
|
+
version: "0.0.1"
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
capabilities: {
|
|
963
|
+
// We advertise tools only — no resources/prompts in v1. The
|
|
964
|
+
// SDK auto-fills the capability object from the registrations
|
|
965
|
+
// below, so we just declare an empty placeholder to be
|
|
966
|
+
// explicit about the shape.
|
|
967
|
+
tools: {}
|
|
968
|
+
},
|
|
969
|
+
instructions: "Use these tools to install and configure the Fingerprint Platform SDK (`fingerprint-platform-sdk`) in a user project. Standard flow: `analyze_project` \u2192 `install_sdk` \u2192 `wire_init` \u2192 `set_collector_url` \u2192 `generate_cloudflare_worker` (recommended) or `generate_dns_fallback` \u2192 `verify_install`. Every tool is read-only on the filesystem and returns a diff/command/env spec for the assistant to apply with its own tools. Use `explain_topic` to fetch a focused brief on origin-allowlist, cloudflare-proxy, dns-cname, api-keys, events-vs-identify, sync-vs-async."
|
|
970
|
+
}
|
|
971
|
+
);
|
|
972
|
+
function render(envelope) {
|
|
973
|
+
const parts = [envelope.summary];
|
|
974
|
+
if (envelope.details) parts.push(envelope.details);
|
|
975
|
+
if (envelope.nextSteps && envelope.nextSteps.length > 0) {
|
|
976
|
+
parts.push(
|
|
977
|
+
"## Next steps\n\n" + envelope.nextSteps.map((s, i) => `${i + 1}. ${s}`).join("\n")
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
if (envelope.artifact) {
|
|
981
|
+
switch (envelope.artifact.kind) {
|
|
982
|
+
case "diff":
|
|
983
|
+
parts.push("## Diff\n\n```diff\n" + envelope.artifact.content + "\n```");
|
|
984
|
+
break;
|
|
985
|
+
case "command":
|
|
986
|
+
parts.push(
|
|
987
|
+
"## Command\n\n```bash\n" + envelope.artifact.content + "\n```" + (envelope.artifact.cwd ? `
|
|
988
|
+
_(run in \`${envelope.artifact.cwd}\`)_` : "")
|
|
989
|
+
);
|
|
990
|
+
break;
|
|
991
|
+
case "env":
|
|
992
|
+
parts.push(
|
|
993
|
+
"## Env vars\n\n```bash\n" + Object.entries(envelope.artifact.content).map(([k, v]) => `${k}=${v}`).join("\n") + "\n```"
|
|
994
|
+
);
|
|
995
|
+
break;
|
|
996
|
+
case "markdown":
|
|
997
|
+
break;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return { content: [{ type: "text", text: parts.join("\n\n") }] };
|
|
1001
|
+
}
|
|
1002
|
+
server.registerTool(
|
|
1003
|
+
"analyze_project",
|
|
1004
|
+
{
|
|
1005
|
+
description: "Scan the user project at `cwd` \u2014 read package.json + lockfile + framework config \u2014 to determine framework / package manager / entry file / whether the SDK is already installed. Read-only. Always call this first.",
|
|
1006
|
+
inputSchema: analyzeInputSchema
|
|
1007
|
+
},
|
|
1008
|
+
async (args) => render(analyzeProject(args))
|
|
1009
|
+
);
|
|
1010
|
+
server.registerTool(
|
|
1011
|
+
"install_sdk",
|
|
1012
|
+
{
|
|
1013
|
+
description: "Emit the install command for `fingerprint-platform-sdk` using the detected (or passed) package manager. Does NOT execute \u2014 the AI runs it via its own bash tool.",
|
|
1014
|
+
inputSchema: installInputSchema
|
|
1015
|
+
},
|
|
1016
|
+
async (args) => render(
|
|
1017
|
+
installSdk({
|
|
1018
|
+
cwd: args.cwd,
|
|
1019
|
+
source: args.source ?? "npm",
|
|
1020
|
+
...args.packageManager ? { packageManager: args.packageManager } : {}
|
|
1021
|
+
})
|
|
1022
|
+
)
|
|
1023
|
+
);
|
|
1024
|
+
server.registerTool(
|
|
1025
|
+
"wire_init",
|
|
1026
|
+
{
|
|
1027
|
+
description: "Generate a unified diff that places the framework-idiomatic SDK init() call into the right entry file. Reuses `initSnippet()` from `@fingerprint79/sdk-snippets` so the code matches what the dashboard shows.",
|
|
1028
|
+
inputSchema: wireInitInputSchema
|
|
1029
|
+
},
|
|
1030
|
+
async (args) => render(
|
|
1031
|
+
wireInit({
|
|
1032
|
+
cwd: args.cwd,
|
|
1033
|
+
framework: args.framework,
|
|
1034
|
+
entryFile: args.entryFile,
|
|
1035
|
+
apiKey: args.apiKey,
|
|
1036
|
+
collectorUrl: args.collectorUrl,
|
|
1037
|
+
source: args.source ?? "npm"
|
|
1038
|
+
})
|
|
1039
|
+
)
|
|
1040
|
+
);
|
|
1041
|
+
server.registerTool(
|
|
1042
|
+
"set_collector_url",
|
|
1043
|
+
{
|
|
1044
|
+
description: "Validate a collector URL and return (a) env-var spec to add to `.env.local` and (b) the dashboard `allowedHosts` change the operator must make manually. The MCP server cannot mutate the dashboard in v1.",
|
|
1045
|
+
inputSchema: setCollectorUrlInputSchema
|
|
1046
|
+
},
|
|
1047
|
+
async (args) => render(setCollectorUrl(args))
|
|
1048
|
+
);
|
|
1049
|
+
server.registerTool(
|
|
1050
|
+
"generate_cloudflare_worker",
|
|
1051
|
+
{
|
|
1052
|
+
description: "Emit a `cloudflare/worker.ts` + `cloudflare/wrangler.toml` as diffs to drop into the user repo. Worker proxies SDK bundle + collector API under `<customDomain>/fpjs/*` for same-origin, ad-block-resistant delivery.",
|
|
1053
|
+
inputSchema: generateCloudflareWorkerInputSchema
|
|
1054
|
+
},
|
|
1055
|
+
async (args) => render(generateCloudflareWorker(args))
|
|
1056
|
+
);
|
|
1057
|
+
server.registerTool(
|
|
1058
|
+
"generate_dns_fallback",
|
|
1059
|
+
{
|
|
1060
|
+
description: "When Cloudflare is not an option, emit DNS CNAME instructions pointing a subdomain at the collector. Keeps cookies first-party but is more ad-block-prone than the Worker route.",
|
|
1061
|
+
inputSchema: generateDnsFallbackInputSchema
|
|
1062
|
+
},
|
|
1063
|
+
async (args) => render(generateDnsFallback(args))
|
|
1064
|
+
);
|
|
1065
|
+
server.registerTool(
|
|
1066
|
+
"verify_install",
|
|
1067
|
+
{
|
|
1068
|
+
description: "Run a post-install sanity checklist on the user project \u2014 SDK in deps, init() call grep-able in source, env vars set. Returns a `{ check, status, hint }[]` triple for each item.",
|
|
1069
|
+
inputSchema: verifyInstallInputSchema
|
|
1070
|
+
},
|
|
1071
|
+
async (args) => render(verifyInstall(args))
|
|
1072
|
+
);
|
|
1073
|
+
server.registerTool(
|
|
1074
|
+
"explain_topic",
|
|
1075
|
+
{
|
|
1076
|
+
description: 'Return a focused markdown brief on one of: origin-allowlist, cloudflare-proxy, dns-cname, api-keys, events-vs-identify, sync-vs-async. Use when the user asks a "why" / "how" question about an advanced setup.',
|
|
1077
|
+
inputSchema: explainInputSchema
|
|
1078
|
+
},
|
|
1079
|
+
async (args) => render(explainTopic(args))
|
|
1080
|
+
);
|
|
1081
|
+
return server;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// src/cli.ts
|
|
1085
|
+
async function main() {
|
|
1086
|
+
const server = buildServer();
|
|
1087
|
+
const transport = new StdioServerTransport();
|
|
1088
|
+
await server.connect(transport);
|
|
1089
|
+
}
|
|
1090
|
+
main().catch((err) => {
|
|
1091
|
+
console.error("[fingerprint-platform-mcp] fatal:", err);
|
|
1092
|
+
process.exit(1);
|
|
1093
|
+
});
|
|
1094
|
+
//# sourceMappingURL=cli.js.map
|