// progression-chain-upsert — MERGE a Sale + Chain + ordered ChainLinks + // party Persons in one transaction, mirroring Loop's chain object. Re-running // over the same chain updates in place (natural-key MERGE, account-scoped, // parent edge in the same statement). import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { lifelineTool } from "../../../../../../../platform/lib/mcp-lifeline/dist/index.js"; import { getSession } from "../lib/neo4j.js"; import { upsertChain, MILESTONE_STAGE_NAMES, PARTY_ROLE_NAMES, type ChainUpsertInput, } from "../lib/chain-graph.js"; const partyShape = z .object({ name: z.string().optional(), email: z.string().optional(), phone: z.string().optional(), }) .describe("A chain party: name plus contact (stored on partyEmail/partyPhone)."); const milestoneShape = z .object( Object.fromEntries( MILESTONE_STAGE_NAMES.map((s) => [s, z.string().describe("ISO-8601 date").optional()]), ) as Record<(typeof MILESTONE_STAGE_NAMES)[number], z.ZodOptional>, ) .describe("Milestone dates keyed by stage; any subset. ISO-8601 strings."); const partiesShape = z .object( Object.fromEntries( PARTY_ROLE_NAMES.map((r) => [r, partyShape.optional()]), ) as Record<(typeof PARTY_ROLE_NAMES)[number], z.ZodOptional>, ) .describe("Parties keyed by role; any subset."); const linkShape = z.object({ priority: z.number().int().describe("Position in the chain (1 = the subject sale)."), linkType: z.enum(["loop-property", "external"]), address: z.string().optional(), salePrice: z.number().int().optional().describe("Sale price in pence."), generalNotes: z.string().optional(), proceedabilityNotes: z.string().optional(), propertySlug: z.string().optional().describe("Property slug for IS_PROPERTY when linkType=loop-property."), milestones: milestoneShape.optional(), parties: partiesShape.optional(), }); export function registerChainUpsert(server: McpServer, accountId: string): void { lifelineTool( server, "progression-chain-upsert", "Create or update a sale-agreed deal's full chain state — the Sale, its Chain, every ChainLink with its ten milestone dates, and the five party roles per link. Idempotent on natural keys: re-running updates in place. The source of truth for chase-progression and progression-inbox; replaces the old markdown pipeline.", { chainId: z.string().describe("Stable chain identifier (Loop chainId or a minted id)."), sale: z.object({ sourceSystem: z.string().describe("e.g. loop-crm / manual."), sourceId: z.string().describe("Natural key of the sale in the source system."), salePrice: z.number().int().optional().describe("Agreed price in pence."), status: z.enum(["sale-agreed", "exchanged", "completed", "fallen-through"]).optional(), sstcDate: z.string().optional().describe("ISO-8601 date the sale was agreed (SSTC)."), isCompleteChain: z.boolean().optional(), }), chain: z .object({ anticipatedExchangeDate: z.string().optional(), desiredCompletionDate: z.string().optional(), createdDate: z.string().optional(), owningTeam: z.string().optional(), owningAgent: z.string().optional(), isComplete: z.boolean().optional(), }) .optional(), links: z.array(linkShape).describe("Ordered chain links; each carries its milestones and parties."), }, async (params: Record) => { const input = params as unknown as ChainUpsertInput; const partyTotal = input.links.reduce( (n, l) => n + Object.keys(l.parties ?? {}).length, 0, ); console.error( `[progression] op=chain-upsert chainId=${input.chainId} sale=${input.sale.sourceId} links=${input.links.length} parties=${partyTotal}`, ); const argProps = [ ...Object.keys(input.sale).map((k) => `sale.${k}`), ...Object.keys(input.chain ?? {}).map((k) => `chain.${k}`), ...input.links.flatMap((l, i) => [ ...Object.keys(l.milestones ?? {}).map((m) => `link[${l.priority ?? i}].${m}`), ...Object.keys(l.parties ?? {}).map((p) => `link[${l.priority ?? i}].party.${p}`), ]), ]; console.error(`[progression] op=args chainId=${input.chainId} props=${argProps.join(",")}`); const session = getSession(); try { const r = await upsertChain(session, accountId, input); console.error( `[progression] op=write-result chainId=${input.chainId} nodes=created:${r.nodesCreated}/props:${r.propertiesSet}/edges:${r.relationshipsCreated} links=${r.linkCount} parties=${r.partyCount} listingLinked=${r.listingLinked} offerLinked=${r.offerLinked} rejected=none`, ); return { content: [ { type: "text" as const, text: JSON.stringify({ ok: true, chainId: input.chainId, ...r }, null, 2), }, ], }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[progression] op=write-result chainId=${input.chainId} rejected=${msg}`); return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true, }; } finally { await session.close(); } }, ); }