@cross-deck/ai 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # Crossdeck AI
2
+
3
+ > Connect Crossdeck to Claude, Cursor, or any MCP client and **ask your app questions in plain English** — and get **rendered charts and dashboards back**, not just numbers. *"Why did revenue drop?" "Who did this crash affect — do they pay us?" "Draw user growth over the last 30 days."*
4
+
5
+ Crossdeck joins the layers about your users — **identity, revenue, entitlements, errors, analytics, and database read-cost** — into one place, by identity. **Crossdeck AI gives that source-of-truth a voice.** MCP is the transport; the product is *Crossdeck understands your app*, in whatever AI tool you use.
6
+
7
+ The point isn't "let AI read my data." It's the **crossing**: because Crossdeck owns the identity that ties the layers together, one question can span them — *who* an error hit **and** how many of them pay you. No single-layer analytics or error tool can answer that.
8
+
9
+ ---
10
+
11
+ ## Test it in 10 minutes
12
+
13
+ ### 1. Connect
14
+
15
+ **Directory / remote (recommended):** add the Crossdeck connector in your client's connector settings and complete the one-click sign-in — you'll authorize Crossdeck and pick a project. No keys to paste. (OAuth 2.0.)
16
+
17
+ **Local (Claude Desktop / Cursor / Claude Code):** install and pass a secret key from your Crossdeck dashboard → **API keys**:
18
+
19
+ ```bash
20
+ npm install -g @cross-deck/ai
21
+ ```
22
+ ```json
23
+ {
24
+ "mcpServers": {
25
+ "crossdeck": { "command": "crossdeck-ai", "env": { "CROSSDECK_SECRET_KEY": "cd_sk_live_…" } }
26
+ }
27
+ }
28
+ ```
29
+ Use a **secret** key (`cd_sk_`), never a publishable (`cd_pub_`) one — these are server-side reads of private data. `CROSSDECK_API_BASE` overrides the endpoint (e.g. sandbox).
30
+
31
+ ### 2. Try these prompts
32
+
33
+ | Ask | Tool(s) used | Expected outcome |
34
+ |---|---|---|
35
+ | *"What's our MRR and how many paying customers, split by Stripe/Apple/Google?"* | `get_revenue` | Current MRR in dollars, paying-customer count, and the three-rail split. |
36
+ | *"Draw our user growth over the last 30 days."* | `draw_user_growth` | A **rendered line chart** of unique visitors + page views over time, with totals. |
37
+ | *"This error `a1b2c3` — who did it affect, and do any of them pay us?"* | `get_error_impact` | The issue's type/status/occurrences plus **affected users and how many are paying**. |
38
+ | *"Show me everything about customer `agent_8842`."* | `get_customer` → moat dashboard | A **rendered cross-match**: what they pay × entitlements × read-cost, in one view. |
39
+ | *"What's driving our database reads — per-user vs overhead?"* | `get_read_cost` | The per-user-vs-overhead split + reads by operation. |
40
+
41
+ ### 3. What you'll see
42
+
43
+ Data tools return clean JSON; the **`draw_user_growth`** and **`open_moat_dashboard`** tools render interactive charts/dashboards inline (in hosts that support MCP Apps; they fall back to a text summary elsewhere).
44
+
45
+ ---
46
+
47
+ ## Tools
48
+
49
+ | Tool | Title | Answers |
50
+ |---|---|---|
51
+ | `get_revenue` | Get revenue | MRR / paying count / per-rail / trend |
52
+ | `get_read_cost` | Get database read-cost | per-user vs overhead, by operation |
53
+ | `get_error_impact` | Get who an error affected | who it hit + how many pay |
54
+ | `get_customer` | Get a customer's full picture | revenue × entitlements × read-cost |
55
+ | `get_host_analytics` | Get analytics for a host | per-subdomain views + uniques |
56
+ | `get_host_top_pages` | Get top pages/referrers for a host | per-host breakdown |
57
+ | `draw_user_growth` | Draw user growth over time | **rendered chart** |
58
+ | `open_moat_dashboard` | Open the cross-layer dashboard | **rendered cross-match** |
59
+
60
+ Every tool is **read-only** (`readOnlyHint`), has a human-readable `title`, returns scoped/paginated results, and surfaces actionable errors. All reads are point-reads of maintained ledgers — asking questions never runs up your database bill.
61
+
62
+ ## Known limitations
63
+
64
+ - **Read-only** in v1 — no writes, no config changes (those are a later, separately-gated stage).
65
+ - Per-host analytics require the host to be a **verified origin** of your project.
66
+ - Rendered charts require an MCP-Apps-capable host; other hosts get a text summary.
67
+ - Reporting reflects data going forward from when Crossdeck was connected (historical backfill is separate).
68
+
69
+ ## Privacy
70
+
71
+ Crossdeck AI reads only your own project's aggregate data, scoped by your token. It does **not** read your conversation history or local files. Full policy: **https://cross-deck.com/legal/privacy/**.
72
+
73
+ ## Support
74
+
75
+ **support@cross-deck.com** · docs: **https://cross-deck.com/docs/reporting-api/**
76
+
77
+ ## License
78
+
79
+ MIT.
package/dist/server.js ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Crossdeck AI — MCP server (tools).
4
+ *
5
+ * The product is "Crossdeck understands your app." This server exposes
6
+ * cross-layer, job-oriented tools over the live Reporting API. Crossdeck owns
7
+ * identity, revenue, entitlements, errors, analytics, and read-cost about an
8
+ * app's users, and joins them BY IDENTITY — so a single tool can answer a
9
+ * question that crosses layers (who an error affected AND how many of them
10
+ * pay you), which no single-layer tool can.
11
+ *
12
+ * Directory-grade tool metadata: every tool has a `title` and a `readOnlyHint`
13
+ * annotation; all tools are read-only (no read/write mixing); descriptions are
14
+ * narrow, accurate, and non-promotional; outputs are scoped and paginated;
15
+ * errors are actionable. Logs go to stderr (stdout is the MCP wire).
16
+ *
17
+ * Transport: stdio here (local/dev — Claude Desktop, Cursor, Claude Code with
18
+ * a secret key in env). The directory build wraps these same tools in a
19
+ * Streamable-HTTP + OAuth host (see http.ts / CROSSDECK_AI_MCP_SUBMISSION.md);
20
+ * the tool layer is shared.
21
+ */
22
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
23
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
24
+ import { z } from "zod";
25
+ import { registerCrossdeckTools } from "./tools.js";
26
+ import { registerCrossdeckUi } from "./ui.js";
27
+ const VERSION = "0.2.0";
28
+ const SECRET_KEY = process.env.CROSSDECK_SECRET_KEY ?? "";
29
+ const API_BASE = process.env.CROSSDECK_API_BASE ?? "https://api.cross-deck.com";
30
+ function log(msg) {
31
+ process.stderr.write(`[crossdeck-ai] ${msg}\n`);
32
+ }
33
+ const server = new McpServer({ name: "crossdeck-ai", version: VERSION });
34
+ // The stdio build authenticates with a secret key from the environment; the
35
+ // tool layer takes an auth resolver so the HTTP/OAuth build can swap in a
36
+ // per-request token without touching the tools.
37
+ const toolCtx = { apiBase: API_BASE, getToken: () => SECRET_KEY };
38
+ registerCrossdeckTools(server, toolCtx);
39
+ registerCrossdeckUi(server, toolCtx);
40
+ async function main() {
41
+ await server.connect(new StdioServerTransport());
42
+ log(`Crossdeck AI v${VERSION} ready (api: ${API_BASE}, key: ${SECRET_KEY ? "set" : "MISSING — set CROSSDECK_SECRET_KEY"})`);
43
+ }
44
+ main().catch((e) => {
45
+ log(`fatal: ${String(e)}`);
46
+ process.exit(1);
47
+ });
48
+ export { z };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Integration test — spawns the built stdio server, runs the MCP handshake,
3
+ * and asserts the directory-grade contract: tool count, hard-gate metadata
4
+ * (title + readOnlyHint) on every tool, UI tools linked to ui:// resources,
5
+ * the resources serve the MCP-Apps mime, and unauthenticated calls fail
6
+ * closed with an actionable error. Run: `npm run build && npm test`.
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { spawn } from "node:child_process";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ const SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), "server.js");
14
+ /** Send a batch of JSON-RPC lines to a fresh server and collect responses by id. */
15
+ function rpc(requests, env = {}) {
16
+ return new Promise((resolve, reject) => {
17
+ const child = spawn(process.execPath, [SERVER], { env: { ...process.env, CROSSDECK_SECRET_KEY: "cd_sk_test", ...env }, stdio: ["pipe", "pipe", "ignore"] });
18
+ let buf = "";
19
+ const out = new Map();
20
+ const timer = setTimeout(() => { child.kill(); reject(new Error("timeout")); }, 8000);
21
+ child.stdout.on("data", (d) => {
22
+ buf += d.toString();
23
+ let i;
24
+ while ((i = buf.indexOf("\n")) >= 0) {
25
+ const line = buf.slice(0, i).trim();
26
+ buf = buf.slice(i + 1);
27
+ if (!line)
28
+ continue;
29
+ try {
30
+ const m = JSON.parse(line);
31
+ if (m.id != null)
32
+ out.set(m.id, m);
33
+ }
34
+ catch { /* ignore */ }
35
+ }
36
+ if (out.size >= requests.filter((r) => r.id != null).length) {
37
+ clearTimeout(timer);
38
+ child.kill();
39
+ resolve(out);
40
+ }
41
+ });
42
+ child.on("error", reject);
43
+ for (const r of requests)
44
+ child.stdin.write(JSON.stringify(r) + "\n");
45
+ });
46
+ }
47
+ const INIT = { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "0" } } };
48
+ const INITED = { jsonrpc: "2.0", method: "notifications/initialized" };
49
+ test("every tool has title + readOnlyHint (hard gate)", async () => {
50
+ const r = await rpc([INIT, INITED, { jsonrpc: "2.0", id: 2, method: "tools/list" }]);
51
+ const tools = r.get(2).result.tools;
52
+ assert.equal(tools.length, 8, "expected 8 tools");
53
+ for (const t of tools) {
54
+ assert.ok(t.title && t.title.length <= 64, `${t.name} needs a title ≤64`);
55
+ assert.equal(t.annotations?.readOnlyHint, true, `${t.name} needs readOnlyHint`);
56
+ assert.ok(t.description && t.description.length > 10, `${t.name} needs a description`);
57
+ assert.equal(/\bbest\b|amazing|simply|just use|prefer this/i.test(t.description), false, `${t.name} description must be non-promotional`);
58
+ }
59
+ });
60
+ test("UI tools link to ui:// resources, and resources serve the MCP-Apps mime", async () => {
61
+ const r = await rpc([INIT, INITED, { jsonrpc: "2.0", id: 2, method: "tools/list" }, { jsonrpc: "2.0", id: 3, method: "resources/list" }]);
62
+ const tools = r.get(2).result.tools;
63
+ const ui = tools.filter((t) => t._meta?.ui?.resourceUri);
64
+ assert.equal(ui.length, 2, "expected 2 UI-linked tools");
65
+ for (const t of ui)
66
+ assert.match(t._meta.ui.resourceUri, /^ui:\/\//);
67
+ const resources = r.get(3).result.resources;
68
+ assert.equal(resources.length, 2, "expected 2 ui:// resources");
69
+ for (const res of resources)
70
+ assert.equal(res.mimeType, "text/html;profile=mcp-app");
71
+ });
72
+ test("unauthenticated tool call fails closed with an actionable message", async () => {
73
+ const r = await rpc([INIT, INITED, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "get_revenue", arguments: {} } }], { CROSSDECK_SECRET_KEY: "" });
74
+ const res = r.get(2).result;
75
+ assert.equal(res.isError, true, "no-key call must be an error");
76
+ assert.match(res.content[0].text, /connect|CROSSDECK_SECRET_KEY|authorize/i);
77
+ });
package/dist/tools.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Crossdeck AI — the shared tool layer.
3
+ *
4
+ * Registered identically by the stdio build (server.ts) and the
5
+ * Streamable-HTTP + OAuth build (http.ts). Auth is injected via `getToken`
6
+ * so the transport owns credentials, not the tools.
7
+ *
8
+ * Every tool: directory-grade metadata (`title` ≤64, `readOnlyHint`),
9
+ * read-only, narrow/non-promotional description, scoped + paginated output,
10
+ * actionable errors. The moat is shown by CAPABILITY (cross-layer answers),
11
+ * never by promotional language.
12
+ */
13
+ import { z } from "zod";
14
+ function ok(data) {
15
+ const obj = (data && typeof data === "object" ? data : { value: data });
16
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], structuredContent: obj };
17
+ }
18
+ function fail(message) {
19
+ return { content: [{ type: "text", text: message }], isError: true };
20
+ }
21
+ /** One scoped GET against the Reporting API. Surfaces the API's typed error
22
+ * envelope to the model as an actionable message (never a bare 500/400). */
23
+ async function cdGet(ctx, path, params) {
24
+ const token = ctx.getToken();
25
+ if (!token || !token.startsWith("cd_")) {
26
+ return fail("Not connected to Crossdeck. Authorize the connector (or, for the local build, set CROSSDECK_SECRET_KEY to a cd_sk_ key from your Crossdeck dashboard → API keys).");
27
+ }
28
+ const qs = new URLSearchParams();
29
+ for (const [k, v] of Object.entries(params))
30
+ if (v !== undefined && v !== "")
31
+ qs.set(k, String(v));
32
+ const url = `${ctx.apiBase}${path}${qs.toString() ? `?${qs}` : ""}`;
33
+ try {
34
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } });
35
+ const body = (await res.json().catch(() => ({})));
36
+ if (!res.ok) {
37
+ if (res.status === 429)
38
+ return fail(`Crossdeck rate limit reached. Wait briefly and retry. ${body.error?.message ?? ""}`.trim());
39
+ if (res.status === 401)
40
+ return fail("Crossdeck rejected the credentials — reconnect the connector.");
41
+ if (res.status === 403)
42
+ return fail(`Not permitted: ${body.error?.message ?? "this resource is outside your project's scope."}`);
43
+ return fail(`Crossdeck API ${res.status} ${body.error?.code ?? ""}: ${body.error?.message ?? "request failed"}`.trim());
44
+ }
45
+ return ok({ data: body.data ?? body, meta: body.meta });
46
+ }
47
+ catch {
48
+ return fail(`Could not reach Crossdeck (${ctx.apiBase}). Check connectivity and retry.`);
49
+ }
50
+ }
51
+ const RO = { readOnlyHint: true };
52
+ export function registerCrossdeckTools(server, ctx) {
53
+ server.registerTool("get_revenue", {
54
+ title: "Get revenue",
55
+ description: "Get the app's current recurring revenue: MRR (cents), paying-customer count, and the per-rail split across Stripe, Apple, and Google. Pass granularity='day' with a days window for a daily trend. Use for questions about MRR, paying customers, or revenue trend.",
56
+ inputSchema: {
57
+ granularity: z.enum(["total", "day"]).optional().describe("'total' (default) for the latest snapshot, or 'day' for a daily series."),
58
+ days: z.number().int().min(1).max(366).optional().describe("With granularity='day', the trend window (default 90)."),
59
+ },
60
+ annotations: RO,
61
+ }, async ({ granularity, days }) => cdGet(ctx, "/v1/revenue", { granularity, days }));
62
+ server.registerTool("get_read_cost", {
63
+ title: "Get database read-cost",
64
+ description: "Get the app's database read-cost split into per-user reads vs un-attributed overhead, with a breakdown by operation, over the last `days` days. Per-user attribution is possible because Crossdeck joins read-cost to the SDK's identity. Use for questions about what drives database reads or which operation costs the most.",
65
+ inputSchema: { days: z.number().int().min(1).max(90).optional().describe("Window in days (default 30).") },
66
+ annotations: RO,
67
+ }, async ({ days }) => cdGet(ctx, "/v1/buckets", { days }));
68
+ server.registerTool("get_error_impact", {
69
+ title: "Get who an error affected",
70
+ description: "For one error (by fingerprint/issue id), get how many distinct users hit it and how many of those are PAYING customers, plus its type, status, occurrence count, and first/last seen. Joins the error to identity and revenue. Returns counts only — no stack traces, no user identities.",
71
+ inputSchema: { fingerprint: z.string().min(1).describe("The error's fingerprint / issue id from the Crossdeck Errors view.") },
72
+ annotations: RO,
73
+ }, async ({ fingerprint }) => cdGet(ctx, "/v1/errors", { fingerprint }));
74
+ server.registerTool("get_customer", {
75
+ title: "Get a customer's full picture",
76
+ description: "Get one customer across every layer Crossdeck joins by identity: what they pay (monthly cents), their active entitlement count, and their database read-cost. Identify them by any of: your own user id, an anonymous id, a Crossdeck customer id, or a rail transaction id. Use for 'how much does this user pay and what do they cost us?'.",
77
+ inputSchema: {
78
+ userId: z.string().optional().describe("Your own user id for this person (what you pass to identify())."),
79
+ anonymousId: z.string().optional().describe("A pre-login anonymous/device id."),
80
+ customerId: z.string().optional().describe("A Crossdeck customer id (cdcust_…)."),
81
+ appleOriginalTransactionId: z.string().optional().describe("Apple StoreKit originalTransactionId."),
82
+ googlePurchaseToken: z.string().optional().describe("Google Play purchase token."),
83
+ stripeCustomerId: z.string().optional().describe("Stripe customer id (cus_…)."),
84
+ },
85
+ annotations: RO,
86
+ }, async (args) => {
87
+ if (!args.userId && !args.anonymousId && !args.customerId && !args.appleOriginalTransactionId && !args.googlePurchaseToken && !args.stripeCustomerId) {
88
+ return fail("Identify the customer with at least one of: userId, anonymousId, customerId, appleOriginalTransactionId, googlePurchaseToken, or stripeCustomerId.");
89
+ }
90
+ return cdGet(ctx, "/v1/crossmatch", { ...args });
91
+ });
92
+ server.registerTool("get_host_analytics", {
93
+ title: "Get analytics for a host",
94
+ description: "Get page views and unique visitors for one host or subdomain you own (e.g. a tenant's subdomain). Pass granularity='day' for a daily series. The host must be a verified origin of your project, or the request is rejected. Use for per-tenant analytics questions.",
95
+ inputSchema: {
96
+ host: z.string().min(1).describe("The host, e.g. 'wes.example.com'. Must belong to your project."),
97
+ granularity: z.enum(["total", "day"]).optional().describe("'total' (default) or 'day' for a daily series."),
98
+ days: z.number().int().min(1).max(90).optional().describe("Window in days (default 30)."),
99
+ },
100
+ annotations: RO,
101
+ }, async ({ host, granularity, days }) => cdGet(ctx, "/v1/reporting/metrics", { host, granularity, days }));
102
+ server.registerTool("get_host_top_pages", {
103
+ title: "Get top pages or referrers for a host",
104
+ description: "Get the top pages or top referrers for one host you own, paginated via `limit`. Set dimension='top_referrers' for referrers (default is top pages). The host must belong to your project. Use for 'most-viewed pages on this subdomain' or 'where its traffic comes from'.",
105
+ inputSchema: {
106
+ host: z.string().min(1).describe("The host, e.g. 'wes.example.com'. Must belong to your project."),
107
+ dimension: z.enum(["top_pages", "top_referrers"]).optional().describe("'top_pages' (default) or 'top_referrers'."),
108
+ days: z.number().int().min(1).max(90).optional().describe("Window in days (default 30)."),
109
+ limit: z.number().int().min(1).max(100).optional().describe("Max rows (default 25)."),
110
+ },
111
+ annotations: RO,
112
+ }, async ({ host, dimension, days, limit }) => cdGet(ctx, "/v1/reporting/breakdown", { host, dimension, days, limit }));
113
+ }
@@ -0,0 +1,112 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Crossdeck — Customer cross-match</title>
7
+ <style>
8
+ :root {
9
+ --bg: var(--color-background-primary, #ffffff);
10
+ --fg: var(--color-text-primary, #1a1a1a);
11
+ --muted: var(--color-text-secondary, #6b7280);
12
+ --grid: var(--color-border-primary, #e5e7eb);
13
+ --accent: var(--color-accent-primary, #e8552a);
14
+ --surface: var(--color-background-secondary, #f7f3ec);
15
+ --good: var(--color-success, #16a34a);
16
+ font-family: var(--font-family-base, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
17
+ }
18
+ * { box-sizing: border-box; }
19
+ body { margin: 0; background: var(--bg); color: var(--fg); padding: 16px; }
20
+ h1 { font-size: 15px; font-weight: 650; margin: 0 0 2px; }
21
+ .who { color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
22
+ .sentence { background: var(--surface); border: 1px solid var(--grid); border-radius: 10px;
23
+ padding: 12px 14px; margin: 14px 0; font-size: 15px; line-height: 1.5; }
24
+ .sentence b { font-weight: 680; }
25
+ .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
26
+ .card { background: var(--surface); border: 1px solid var(--grid); border-radius: 10px; padding: 12px 14px; }
27
+ .card .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
28
+ .card .value { font-size: 24px; font-weight: 680; letter-spacing: -0.01em; margin-top: 4px; }
29
+ .card .note { color: var(--muted); font-size: 11px; margin-top: 3px; }
30
+ .pill { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--grid); }
31
+ .pill.paying { color: var(--good); border-color: var(--good); }
32
+ .foot { color: var(--muted); font-size: 11px; margin-top: 12px; }
33
+ .empty { color: var(--muted); font-size: 13px; padding: 28px 0; text-align: center; }
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <h1 id="title">Customer cross-match</h1>
38
+ <div class="who" id="who"></div>
39
+ <div id="body"></div>
40
+
41
+ <script>
42
+ (function () {
43
+ "use strict";
44
+ function money(cents) {
45
+ if (cents == null) return "—";
46
+ return "$" + (cents / 100).toLocaleString(undefined, { minimumFractionDigits: cents % 100 ? 2 : 0, maximumFractionDigits: 2 });
47
+ }
48
+ function num(n) {
49
+ if (n == null) return "—";
50
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
51
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
52
+ return String(n);
53
+ }
54
+
55
+ function render(payload) {
56
+ var d = (payload && payload.data) ? payload.data : payload || {};
57
+ var body = document.getElementById("body");
58
+ if (!d || d === null || (!d.customer && !d.revenue && !d.readCost)) {
59
+ document.getElementById("who").textContent = "";
60
+ body.innerHTML = '<div class="empty">No customer matched that identifier.</div>';
61
+ return;
62
+ }
63
+ var cust = d.customer || {};
64
+ var rev = d.revenue || {};
65
+ var ent = d.entitlements || {};
66
+ var rc = d.readCost || null;
67
+
68
+ document.getElementById("who").textContent = cust.crossdeckCustomerId || "";
69
+
70
+ // The sentence only Crossdeck can produce — the moat, in words.
71
+ var paysPart = rev.paying ? ("pays <b>" + money(rev.monthlyCents) + "/mo</b>") : "is <b>not paying</b>";
72
+ var entPart = "holds <b>" + (ent.active != null ? ent.active : 0) + "</b> entitlement" + (ent.active === 1 ? "" : "s");
73
+ var costPart = rc ? ("cost you <b>" + num(rc.reads) + " reads</b> in the last " + rc.windowDays + " days") : "has no attributed read-cost in range";
74
+ document.getElementById("title").textContent = "Customer cross-match";
75
+
76
+ var sentence = '<div class="sentence">This customer ' + paysPart + ", " + entPart + ", and " + costPart + ".</div>";
77
+
78
+ var cards =
79
+ '<div class="cards">' +
80
+ '<div class="card"><div class="label">Revenue</div><div class="value">' + money(rev.monthlyCents) +
81
+ '</div><div class="note">' + (rev.paying ? '<span class="pill paying">paying</span>' : '<span class="pill">free</span>') + '</div></div>' +
82
+ '<div class="card"><div class="label">Entitlements</div><div class="value">' + (ent.active != null ? ent.active : 0) +
83
+ '</div><div class="note">active</div></div>' +
84
+ '<div class="card"><div class="label">Read-cost</div><div class="value">' + (rc ? num(rc.reads) : "—") +
85
+ '</div><div class="note">' + (rc ? ("last " + rc.windowDays + "d") : "n/a") + '</div></div>' +
86
+ '</div>';
87
+
88
+ body.innerHTML = sentence + cards +
89
+ '<div class="foot">Revenue × entitlements × read-cost, joined by identity — the cross-layer view only Crossdeck can produce.</div>';
90
+ }
91
+
92
+ function applyTheme(hostContext) {
93
+ if (!hostContext) return;
94
+ var vars = (hostContext.styles && hostContext.styles.variables) || {};
95
+ var root = document.documentElement;
96
+ Object.keys(vars).forEach(function (k) { root.style.setProperty(k.charAt(0) === "-" ? k : ("--" + k), vars[k]); });
97
+ if (hostContext.theme) { root.setAttribute("data-theme", hostContext.theme); root.style.colorScheme = hostContext.theme; }
98
+ }
99
+ function onResult(r) { render(r && r.structuredContent ? r.structuredContent : r); }
100
+ window.addEventListener("message", function (ev) {
101
+ var m = ev.data || {};
102
+ if (m.result && m.result.hostContext) applyTheme(m.result.hostContext);
103
+ if (m.method === "ui/notifications/host-context-changed" && m.params) applyTheme(m.params);
104
+ if (m.method === "ui/notifications/tool-input" && m.params && m.params.structuredContent) onResult(m.params);
105
+ if (m.method === "ui/notifications/tool-result" && m.params) onResult(m.params);
106
+ });
107
+ if (window.__CROSSDECK_DATA__) render(window.__CROSSDECK_DATA__);
108
+ try { parent.postMessage({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: { clientInfo: { name: "crossdeck-moat-dashboard", version: "1.0.0" } } }, "*"); } catch (e) {}
109
+ })();
110
+ </script>
111
+ </body>
112
+ </html>
@@ -0,0 +1,135 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Crossdeck — User growth</title>
7
+ <style>
8
+ /* Theme-aware: use the host's CSS custom properties with safe fallbacks
9
+ (MCP Apps hosts inject --color-* vars; we degrade gracefully). */
10
+ :root {
11
+ --bg: var(--color-background-primary, #ffffff);
12
+ --fg: var(--color-text-primary, #1a1a1a);
13
+ --muted: var(--color-text-secondary, #6b7280);
14
+ --grid: var(--color-border-primary, #e5e7eb);
15
+ --accent: var(--color-accent-primary, #e8552a); /* Crossdeck orange */
16
+ --accent2: var(--color-accent-secondary, #2563eb);
17
+ --surface: var(--color-background-secondary, #f7f3ec);
18
+ font-family: var(--font-family-base, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
19
+ }
20
+ * { box-sizing: border-box; }
21
+ body { margin: 0; background: var(--bg); color: var(--fg); padding: 16px; }
22
+ .head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 4px; }
23
+ h1 { font-size: 15px; font-weight: 650; margin: 0; }
24
+ .sub { color: var(--muted); font-size: 12px; }
25
+ .totals { display: flex; gap: 18px; margin: 10px 0 14px; }
26
+ .kpi { display: flex; flex-direction: column; }
27
+ .kpi b { font-size: 22px; font-weight: 680; letter-spacing: -0.01em; }
28
+ .kpi span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
29
+ .legend { display: flex; gap: 14px; font-size: 12px; color: var(--muted); margin-top: 8px; }
30
+ .legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
31
+ svg { width: 100%; height: auto; display: block; }
32
+ .empty { color: var(--muted); font-size: 13px; padding: 28px 0; text-align: center; }
33
+ .tip { fill: var(--fg); font-size: 11px; }
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <div class="head">
38
+ <h1 id="title">User growth</h1>
39
+ <span class="sub" id="range"></span>
40
+ </div>
41
+ <div class="totals" id="totals"></div>
42
+ <div id="chart"></div>
43
+ <div class="legend">
44
+ <span><i style="background:var(--accent)"></i>Unique visitors</span>
45
+ <span><i style="background:var(--accent2)"></i>Page views</span>
46
+ </div>
47
+
48
+ <script>
49
+ (function () {
50
+ "use strict";
51
+
52
+ function fmt(n) {
53
+ if (n == null) return "—";
54
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
55
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
56
+ return String(n);
57
+ }
58
+
59
+ // Render a two-line SVG chart (unique visitors + page views) over time.
60
+ function render(data) {
61
+ var d = (data && data.data) ? data.data : data || {};
62
+ var series = Array.isArray(d.series) ? d.series : [];
63
+ var host = d.host || "";
64
+ var totals = d.totals || {};
65
+ document.getElementById("title").textContent = host ? ("User growth · " + host) : "User growth";
66
+ var r = d.range || {};
67
+ document.getElementById("range").textContent = (r.from && r.to) ? (r.from + " → " + r.to) : "";
68
+
69
+ document.getElementById("totals").innerHTML =
70
+ '<div class="kpi"><b>' + fmt(totals.uniqueVisitors) + '</b><span>Unique visitors</span></div>' +
71
+ '<div class="kpi"><b>' + fmt(totals.views) + '</b><span>Page views</span></div>';
72
+
73
+ var chart = document.getElementById("chart");
74
+ if (series.length === 0) { chart.innerHTML = '<div class="empty">No analytics yet for this range.</div>'; return; }
75
+
76
+ var W = 720, H = 240, padL = 44, padR = 12, padT = 12, padB = 26;
77
+ var iw = W - padL - padR, ih = H - padT - padB;
78
+ var n = series.length;
79
+ var maxY = 1;
80
+ series.forEach(function (p) { maxY = Math.max(maxY, p.uniqueVisitors || 0, p.views || 0); });
81
+ // round max up to a "nice" number
82
+ var pow = Math.pow(10, Math.floor(Math.log10(maxY)));
83
+ maxY = Math.ceil(maxY / pow) * pow;
84
+
85
+ function x(i) { return padL + (n <= 1 ? iw / 2 : (i / (n - 1)) * iw); }
86
+ function y(v) { return padT + ih - (v / maxY) * ih; }
87
+ function path(key) {
88
+ return series.map(function (p, i) { return (i ? "L" : "M") + x(i).toFixed(1) + " " + y(p[key] || 0).toFixed(1); }).join(" ");
89
+ }
90
+
91
+ var grid = "", ticks = 4;
92
+ for (var g = 0; g <= ticks; g++) {
93
+ var gy = padT + (g / ticks) * ih, gv = maxY - (g / ticks) * maxY;
94
+ grid += '<line x1="' + padL + '" y1="' + gy.toFixed(1) + '" x2="' + (W - padR) + '" y2="' + gy.toFixed(1) + '" stroke="var(--grid)" stroke-width="1"/>';
95
+ grid += '<text x="' + (padL - 6) + '" y="' + (gy + 3).toFixed(1) + '" text-anchor="end" fill="var(--muted)" font-size="10">' + fmt(Math.round(gv)) + '</text>';
96
+ }
97
+ // x labels: first, middle, last
98
+ var xl = "";
99
+ [0, Math.floor((n - 1) / 2), n - 1].filter(function (v, i, a) { return a.indexOf(v) === i; }).forEach(function (i) {
100
+ xl += '<text x="' + x(i).toFixed(1) + '" y="' + (H - 8) + '" text-anchor="middle" fill="var(--muted)" font-size="10">' + (series[i].date || "") + '</text>';
101
+ });
102
+
103
+ chart.innerHTML =
104
+ '<svg viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="User growth over time">' +
105
+ grid + xl +
106
+ '<path d="' + path("views") + '" fill="none" stroke="var(--accent2)" stroke-width="2" stroke-linejoin="round"/>' +
107
+ '<path d="' + path("uniqueVisitors") + '" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linejoin="round"/>' +
108
+ '</svg>';
109
+ }
110
+
111
+ // ── MCP Apps host bridge (spec 2026-01-26) ─────────────────────────────
112
+ // Self-contained JSON-RPC-over-postMessage: send ui/initialize, apply the
113
+ // host theme from the result's hostContext, then render on the
114
+ // ui/notifications/tool-result (the CallToolResult — read structuredContent).
115
+ function applyTheme(hostContext) {
116
+ if (!hostContext) return;
117
+ var vars = (hostContext.styles && hostContext.styles.variables) || {};
118
+ var root = document.documentElement;
119
+ Object.keys(vars).forEach(function (k) { root.style.setProperty(k.charAt(0) === "-" ? k : ("--" + k), vars[k]); });
120
+ if (hostContext.theme) { root.setAttribute("data-theme", hostContext.theme); root.style.colorScheme = hostContext.theme; }
121
+ }
122
+ function onResult(r) { render(r && r.structuredContent ? r.structuredContent : r); }
123
+ window.addEventListener("message", function (ev) {
124
+ var m = ev.data || {};
125
+ if (m.result && m.result.hostContext) applyTheme(m.result.hostContext); // ui/initialize response
126
+ if (m.method === "ui/notifications/host-context-changed" && m.params) applyTheme(m.params);
127
+ if (m.method === "ui/notifications/tool-input" && m.params && m.params.structuredContent) onResult(m.params);
128
+ if (m.method === "ui/notifications/tool-result" && m.params) onResult(m.params);
129
+ });
130
+ if (window.__CROSSDECK_DATA__) render(window.__CROSSDECK_DATA__); // local preview
131
+ try { parent.postMessage({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: { clientInfo: { name: "crossdeck-user-growth", version: "1.0.0" } } }, "*"); } catch (e) {}
132
+ })();
133
+ </script>
134
+ </body>
135
+ </html>
package/dist/ui.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Crossdeck AI — MCP Apps (rendered UI).
3
+ *
4
+ * Two `ui://` HTML resources + two app tools that return data and link to
5
+ * them. Hosts that support MCP Apps render the chart/dashboard in a sandboxed
6
+ * iframe (the HTML reads `structuredContent` from the tool result and the host
7
+ * theme from `hostContext.styles.variables`); other hosts get the text fallback.
8
+ *
9
+ * Uses the first-class @modelcontextprotocol/ext-apps helpers
10
+ * (registerAppResource / registerAppTool / RESOURCE_MIME_TYPE).
11
+ */
12
+ import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server";
13
+ import { z } from "zod";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
18
+ function html(name) {
19
+ try {
20
+ return fs.readFileSync(path.join(UI_DIR, name), "utf8");
21
+ }
22
+ catch {
23
+ return "<!doctype html><meta charset=utf-8><body style='font:14px system-ui;padding:16px'>Crossdeck UI unavailable.</body>";
24
+ }
25
+ }
26
+ async function fetchData(ctx, p, params) {
27
+ const token = ctx.getToken();
28
+ if (!token || !token.startsWith("cd_"))
29
+ return { error: "Not connected to Crossdeck — authorize the connector." };
30
+ const qs = new URLSearchParams();
31
+ for (const [k, v] of Object.entries(params))
32
+ if (v !== undefined && v !== "")
33
+ qs.set(k, String(v));
34
+ try {
35
+ const res = await fetch(`${ctx.apiBase}${p}${qs.toString() ? `?${qs}` : ""}`, {
36
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
37
+ });
38
+ const body = (await res.json().catch(() => ({})));
39
+ if (!res.ok)
40
+ return { error: body.error?.message ?? `Crossdeck request failed (${res.status}).` };
41
+ return { data: body.data ?? body };
42
+ }
43
+ catch {
44
+ return { error: `Could not reach Crossdeck (${ctx.apiBase}).` };
45
+ }
46
+ }
47
+ const RO = { readOnlyHint: true };
48
+ const GROWTH_URI = "ui://crossdeck/user-growth";
49
+ const MOAT_URI = "ui://crossdeck/moat-dashboard";
50
+ export function registerCrossdeckUi(server, ctx) {
51
+ // ── User-growth chart ──────────────────────────────────────────────────
52
+ registerAppResource(server, GROWTH_URI, GROWTH_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
53
+ contents: [{ uri: GROWTH_URI, mimeType: RESOURCE_MIME_TYPE, text: html("user-growth.html") }],
54
+ }));
55
+ registerAppTool(server, "draw_user_growth", {
56
+ title: "Draw user growth over time",
57
+ description: "Render an interactive line chart of unique visitors and page views over time for a host you own. Use when asked to chart, graph, draw, or visualize growth or traffic for a subdomain. The host must be a verified origin of your project.",
58
+ inputSchema: {
59
+ host: z.string().min(1).describe("The host, e.g. 'wes.example.com'."),
60
+ days: z.number().int().min(1).max(90).optional().describe("Window in days (default 30)."),
61
+ },
62
+ _meta: { ui: { resourceUri: GROWTH_URI } },
63
+ annotations: RO,
64
+ }, async ({ host, days }) => {
65
+ const r = await fetchData(ctx, "/v1/reporting/metrics", { host, granularity: "day", days: days ?? 30 });
66
+ if (r.error)
67
+ return { content: [{ type: "text", text: r.error }], isError: true };
68
+ const d = (r.data ?? {});
69
+ const t = d.totals ?? {};
70
+ return {
71
+ content: [{ type: "text", text: `User growth for ${host}: ${t.uniqueVisitors ?? 0} unique visitors, ${t.views ?? 0} page views over the range.` }],
72
+ structuredContent: d,
73
+ };
74
+ });
75
+ // ── Cross-layer customer dashboard (the moat, rendered) ────────────────
76
+ registerAppResource(server, MOAT_URI, MOAT_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
77
+ contents: [{ uri: MOAT_URI, mimeType: RESOURCE_MIME_TYPE, text: html("moat-dashboard.html") }],
78
+ }));
79
+ registerAppTool(server, "open_moat_dashboard", {
80
+ title: "Open the cross-layer dashboard",
81
+ description: "Render a customer's cross-layer dashboard — what they pay, their active entitlements, and their database read-cost, joined by identity. Identify the customer by any of userId, anonymousId, customerId, or a rail transaction id.",
82
+ inputSchema: {
83
+ userId: z.string().optional(),
84
+ anonymousId: z.string().optional(),
85
+ customerId: z.string().optional(),
86
+ appleOriginalTransactionId: z.string().optional(),
87
+ googlePurchaseToken: z.string().optional(),
88
+ stripeCustomerId: z.string().optional(),
89
+ },
90
+ _meta: { ui: { resourceUri: MOAT_URI } },
91
+ annotations: RO,
92
+ }, async (args) => {
93
+ if (!Object.values(args).some(Boolean)) {
94
+ return { content: [{ type: "text", text: "Identify the customer with at least one of: userId, anonymousId, customerId, appleOriginalTransactionId, googlePurchaseToken, stripeCustomerId." }], isError: true };
95
+ }
96
+ const r = await fetchData(ctx, "/v1/crossmatch", { ...args });
97
+ if (r.error)
98
+ return { content: [{ type: "text", text: r.error }], isError: true };
99
+ const d = (r.data ?? {});
100
+ const rev = d.revenue ?? {};
101
+ const ent = d.entitlements ?? {};
102
+ const rc = d.readCost;
103
+ const summary = d.revenue || d.readCost
104
+ ? `Pays ${rev.monthlyCents != null ? "$" + (rev.monthlyCents / 100).toFixed(2) : "—"}/mo, ${ent.active ?? 0} entitlements${rc ? `, ${rc.reads} reads / ${rc.windowDays}d` : ""}.`
105
+ : "No customer matched that identifier.";
106
+ return { content: [{ type: "text", text: summary }], structuredContent: (d ?? {}) };
107
+ });
108
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@cross-deck/ai",
3
+ "version": "0.2.0",
4
+ "description": "Crossdeck AI — the MCP connector that lets Claude, Cursor, and any AI tool query and operate your Crossdeck app. The product is 'Crossdeck understands your app'; MCP is just the transport.",
5
+ "type": "module",
6
+ "bin": {
7
+ "crossdeck-ai": "dist/server.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc && node -e \"require('fs').cpSync('ui','dist/ui',{recursive:true})\"",
15
+ "start": "node dist/server.js",
16
+ "test": "node --test dist/*.test.js",
17
+ "dev": "tsc --watch"
18
+ },
19
+ "keywords": [
20
+ "crossdeck",
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "ai",
24
+ "claude",
25
+ "cursor",
26
+ "analytics",
27
+ "revenue",
28
+ "entitlements"
29
+ ],
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "@modelcontextprotocol/ext-apps": "^1.7.4",
33
+ "@modelcontextprotocol/sdk": "^1.0.0",
34
+ "zod": "^3.23.8"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20.19.43",
38
+ "typescript": "^5.5.0"
39
+ }
40
+ }