// buyers MCP server — visitor-graph tools (Task 357 + 362). // // Eight tools, all admin-side (none exposed on the public agent): // visitor-recent-by-person query // visitor-recent-by-page query // visitor-engagement-score query // visitor-recommendation-ctr query (graph-backed CTR) // visitor-session-detail query // visitor-event-ingest write (test harness / manual replay) // visitor-backfill-from-logs write (one-shot recovery importer) // mint-visitor-token mint (Task 362 — sign &v= URLs) import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { lifelineTool } from "../../../../../../platform/lib/mcp-lifeline/dist/index.js"; import { closeDriver } from "./lib/neo4j.js"; import { visitorRecentByPerson } from "./tools/visitor-recent-by-person.js"; import { visitorRecentByPage } from "./tools/visitor-recent-by-page.js"; import { visitorEngagementScore } from "./tools/visitor-engagement-score.js"; import { visitorRecommendationCtr } from "./tools/visitor-recommendation-ctr.js"; import { visitorSessionDetail } from "./tools/visitor-session-detail.js"; import { visitorEventIngest } from "./tools/visitor-event-ingest.js"; import { visitorBackfillFromLogs } from "./tools/visitor-backfill-from-logs.js"; import { mintVisitorTokenTool } from "./tools/mint-visitor-token.js"; const accountId = process.env.ACCOUNT_ID ?? "local"; const server = new McpServer({ name: "maxy-buyers", version: "0.1.0" }); console.error(`[buyers] server started, account=${accountId}`); function jsonHandler(fn: (p: Record) => Promise) { return async (params: Record) => { try { const result = await fn({ ...params, accountId }); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { return { content: [{ type: "text" as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }], isError: true, }; } }; } lifelineTool(server, "visitor-recent-by-person", "List recent visitor sessions for a known :Person. Returns per-session pageview/click/scroll/dwell counts and the listings each session touched. Use for morning-round briefings and 1:1 outreach context.", { personId: z.string().min(1).describe(":Person elementId"), sinceDays: z.number().int().positive().optional().describe("Lookback window in days (default 30)"), limit: z.number().int().positive().max(200).optional().describe("Max sessions to return (default 50)"), }, jsonHandler((p) => visitorRecentByPerson(p as unknown as Parameters[0])), ); lifelineTool(server, "visitor-recent-by-page", "List recent visitors (Person + AnonVisitor) of a given listing slug or URL. Use to answer 'who has shown interest in 12 Oak Street this week?'.", { listingSlug: z.string().optional().describe("Listing slug (one of slug/url required)"), url: z.string().optional().describe("Page URL (one of slug/url required)"), sinceDays: z.number().int().positive().optional().describe("Lookback window in days (default 7)"), limit: z.number().int().positive().max(200).optional().describe("Max visitors to return (default 50)"), }, jsonHandler((p) => visitorRecentByPage(p as unknown as Parameters[0])), ); lifelineTool(server, "visitor-engagement-score", "Engagement-ranked :Person list. score = visits * recency * scroll_depth * dwell. Use to build the nurture-list and prioritise outbound.", { sinceDays: z.number().int().positive().optional().describe("Lookback window in days (default 30)"), limit: z.number().int().positive().max(200).optional().describe("Max persons (default 50)"), }, jsonHandler((p) => visitorEngagementScore(p as unknown as Parameters[0])), ); lifelineTool(server, "visitor-recommendation-ctr", "Graph-backed click-through rate for property recommendations over a window. Replaces the Task 336 log-scan after the 14-day parity cutover.", { sinceDays: z.number().int().positive().optional().describe("Lookback window in days (default 7)"), listingSlug: z.string().optional().describe("Optional listing-slug filter"), }, jsonHandler((p) => visitorRecommendationCtr(p as unknown as Parameters[0])), ); lifelineTool(server, "visitor-session-detail", "Full event timeline for one :Session (pageviews, clicks, scroll milestones in chronological order). Use for diagnosis and outreach prep.", { sessionId: z.string().min(1).describe(":Session.sessionId"), }, jsonHandler((p) => visitorSessionDetail(p as unknown as Parameters[0])), ); lifelineTool(server, "visitor-event-ingest", "Admin-side companion to POST /v/event for test harness and manual replay. Writes a single event into the graph.", { type: z.enum(["pageview", "click", "scroll", "dwell"]).describe("Event type"), sessionId: z.string().min(1).describe("Client-provided session id"), visitorId: z.string().min(1).describe("Client-provided visitor id"), personId: z.string().optional().nullable().describe("Optional :Person elementId override"), url: z.string().min(1).describe("Page URL"), path: z.string().optional(), title: z.string().optional(), pageViewId: z.string().optional(), site: z.string().optional(), referrer: z.string().optional(), label: z.string().optional().describe("Click label (data-track value)"), href: z.string().optional(), depth: z.number().int().min(0).max(100).optional().describe("Scroll depth (25/50/75/100)"), dwellMs: z.number().int().min(0).optional(), occurredAt: z.string().optional().describe("ISO timestamp override"), }, jsonHandler((p) => visitorEventIngest(p as unknown as Parameters[0])), ); lifelineTool(server, "visitor-backfill-from-logs", "One-shot importer: parse [property-recommended] and [property-card-click] log lines and write into the visitor graph. Idempotent for Recommendations; immutable for Click events. Use to recover late-arriving sessions or for ad-hoc forensics; the graph is the canonical aggregation surface.", { logPath: z.string().min(1).describe("Absolute path to the platform log file"), sinceIso: z.string().optional().describe("ISO timestamp lower bound (inclusive)"), untilIso: z.string().optional().describe("ISO timestamp upper bound (exclusive)"), batchSize: z.number().int().positive().max(2000).optional().describe("Lines per Cypher batch (default 200)"), }, jsonHandler((p) => visitorBackfillFromLogs(p as unknown as Parameters[0])), ); lifelineTool(server, "mint-visitor-token", "Mint a signed visitor token bound to a known :Person elementId. Append the returned token to outbound listing URLs as `&v=` (e.g. /listings//click?session=&v=). When the recipient clicks, the UI server verifies the HMAC and writes the same value into the `mxy_v` cookie so subsequent /v/event posts attribute to that :Person. TTL defaults to 30 days. Use from morning-round, lead-nurturing, and vendor-updates whenever the outbound URL goes to a known :Person.", { personId: z.string().min(1).describe(":Person elementId to bind the token to"), ttlDays: z.number().int().positive().max(365).optional().describe("Token lifetime in days (default 30, max 365)"), }, jsonHandler((p) => mintVisitorTokenTool(p as unknown as Parameters[0])), ); process.on("SIGINT", async () => { console.error("[buyers] SIGINT — closing driver"); await closeDriver(); process.exit(0); }); process.on("SIGTERM", async () => { console.error("[buyers] SIGTERM — closing driver"); await closeDriver(); process.exit(0); }); const transport = new StdioServerTransport(); await server.connect(transport); console.error("[buyers] ready");