// Logging-tee was a platform-wrapper concern: the platform's mcp-spawn-tee // pipes our stderr where it wants. In standalone mode (Claude Code native // spawn) the host owns the stderr pipe. Either way, we just write to stderr. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { keyRegister } from "./tools/key-register.js"; import { keyDeregister } from "./tools/key-deregister.js"; import { keyList } from "./tools/key-list.js"; import { valuationSale, valuationRent, prices, pricesRent, soldPrices, demand, yields, growth, nationalHpi, pricesPerSqf, soldPricesPerSqf, crime, floodRisk, councilTax, planningApplications, addressMatchUprn, growthPsf, demandRent, propertyTypes, } from "./tools/endpoints.js"; import { lifelineTool } from "../../../../../../platform/lib/mcp-lifeline/dist/index.js"; import { closeDriver } from "./lib/neo4j.js"; import { KeyNotRegisteredError, PropertyDataError, BadResponseError, } from "./lib/propertydata-api.js"; import { selectKeyStore } from "./lib/key-store.js"; // ACCOUNT_ID is the platform's tenant id; in standalone mode (Claude Code // native spawn) there is no platform tenant, so "local" is the single-tenant // stand-in. Same MCP body, env-selected backend. const accountId = process.env.ACCOUNT_ID ?? "local"; const server = new McpServer({ name: "maxy-property-data", version: "0.1.0", }); const store = selectKeyStore(); console.error( `[property-data] server started, account=${accountId} backend=${store.backendName} path=${store.backendPath}`, ); function errorEnvelope(err: unknown): { content: { type: "text"; text: string }[]; isError: true } { let text: string; if (err instanceof KeyNotRegisteredError) { text = "Error: key-not-registered. Register a PropertyData API key with property-data-key-register before calling this tool."; } else if (err instanceof PropertyDataError) { text = `Error: http-${err.status} from PropertyData. ${err.upstreamMessage}`; } else if (err instanceof BadResponseError) { text = `Error: bad-response from PropertyData (non-JSON, length=${err.length}).`; } else { text = `Error: ${err instanceof Error ? err.message : String(err)}`; } return { content: [{ type: "text" as const, text }], isError: true }; } function endpointHandler

>( fn: (args: P & { accountId: string }) => Promise, ) { return async (params: P) => { try { const text = await fn({ ...params, accountId }); return { content: [{ type: "text" as const, text }] }; } catch (err) { return errorEnvelope(err); } }; } // ───────────────────────────────────────────────────────────────── // Key Management // ───────────────────────────────────────────────────────────────── lifelineTool(server, "property-data-key-register", "Register the account's PropertyData API key. Validates the key against PropertyData's /national-hpi endpoint, encrypts it with AES-256-GCM, and stores it in Neo4j. One key per account; re-registering overwrites the existing key.", { apiKey: z.string().min(8).describe("PropertyData API key from propertydata.co.uk dashboard"), }, async ({ apiKey }) => { try { await keyRegister({ apiKey, accountId }); return { content: [ { type: "text" as const, text: "PropertyData API key registered successfully.", }, ], }; } catch (err) { return errorEnvelope(err); } }, ); lifelineTool(server, "property-data-key-deregister", "Remove the account's PropertyData API key. The encrypted key is permanently deleted from Neo4j.", {}, async () => { try { await keyDeregister({ accountId }); return { content: [{ type: "text" as const, text: "PropertyData API key deregistered." }], }; } catch (err) { return errorEnvelope(err); } }, ); lifelineTool(server, "property-data-key-list", "Show whether a PropertyData API key is registered for this account. Never reveals the key value.", {}, async () => { try { const text = await keyList({ accountId }); return { content: [{ type: "text" as const, text }] }; } catch (err) { return errorEnvelope(err); } }, ); // ───────────────────────────────────────────────────────────────── // Valuation endpoints // ───────────────────────────────────────────────────────────────── const valuationSchema = { postcode: z.string().min(2).describe("UK postcode (outcode or full)"), type: z .enum(["detached", "semi-detached", "terraced", "flat"]) .describe("Property type"), bedrooms: z.number().int().min(0).describe("Bedroom count"), bathrooms: z.number().int().min(0).optional().describe("Bathroom count"), internal_area: z.number().optional().describe("Internal area (sq ft)"), construction_date: z.string().optional().describe("Construction date band (e.g. 'pre1900', '1900-1929')"), finish_quality: z .enum(["luxury", "above_average", "average", "below_average", "needs_modernisation"]) .optional() .describe("Finish quality"), }; lifelineTool(server, "property-data-valuation-sale", "Point sale-price estimate for a property given postcode + attributes. Returns point estimate plus comparables.", valuationSchema, endpointHandler(valuationSale), ); lifelineTool(server, "property-data-valuation-rent", "Point rent estimate (PCM) for a property given postcode + attributes. Returns point estimate plus comparables.", valuationSchema, endpointHandler(valuationRent), ); // ───────────────────────────────────────────────────────────────── // Asking-price distributions // ───────────────────────────────────────────────────────────────── const pricesSchema = { postcode: z.string().min(2).describe("UK postcode (outcode or full)"), type: z .enum(["detached", "semi-detached", "terraced", "flat"]) .optional() .describe("Filter by property type"), bedrooms: z.number().int().min(0).optional().describe("Filter by bedroom count"), }; lifelineTool(server, "property-data-prices", "Asking-price distribution for sale listings in a postcode — mean, median, 80th percentile.", pricesSchema, endpointHandler(prices), ); lifelineTool(server, "property-data-prices-rent", "Asking-price distribution for rental listings in a postcode — mean, median, 80th percentile (PCM).", pricesSchema, endpointHandler(pricesRent), ); // ───────────────────────────────────────────────────────────────── // Sold prices (Land Registry) // ───────────────────────────────────────────────────────────────── lifelineTool(server, "property-data-sold-prices", "Land Registry sold transactions for a postcode, optionally filtered by age, type, and bedrooms.", { postcode: z.string().min(2).describe("UK postcode (outcode or full)"), max_age: z.number().int().min(1).optional().describe("Max transaction age in months"), type: z .enum(["detached", "semi-detached", "terraced", "flat"]) .optional() .describe("Filter by property type"), bedrooms: z.number().int().min(0).optional().describe("Filter by bedroom count"), }, endpointHandler(soldPrices), ); // ───────────────────────────────────────────────────────────────── // Demand / yields / growth / national HPI // ───────────────────────────────────────────────────────────────── lifelineTool(server, "property-data-demand", "Demand signals for a postcode — count of properties for sale and average days to sell-STC.", { postcode: z.string().min(2).describe("UK postcode (outcode or full)") }, endpointHandler(demand), ); lifelineTool(server, "property-data-yields", "Gross rental yield for a postcode and bedroom count.", { postcode: z.string().min(2).describe("UK postcode (outcode or full)"), bedrooms: z.number().int().min(0).describe("Bedroom count"), }, endpointHandler(yields), ); lifelineTool(server, "property-data-growth", "Short-term and long-term price growth signals for a postcode.", { postcode: z.string().min(2).describe("UK postcode (outcode or full)") }, endpointHandler(growth), ); lifelineTool(server, "property-data-national-hpi", "UK national house-price index series. No postcode required.", {}, endpointHandler(nationalHpi), ); // ───────────────────────────────────────────────────────────────── // Task 144 — £/sqft baselines and growth // ───────────────────────────────────────────────────────────────── const pricesPerSqfSchema = { postcode: z.string().min(2).describe("UK postcode (outcode or full)"), type: z .enum(["detached", "semi-detached", "terraced", "flat"]) .optional() .describe("Filter by property type"), bedrooms: z.number().int().min(0).optional().describe("Filter by bedroom count"), }; lifelineTool(server, "property-data-prices-per-sqf", "Asking £/sqft distribution for sale listings in a postcode — avg plus 70/80/90/100% percentile ranges, with the raw listings that have square-foot data. Use for the asking £/sqft KPI on a market report. Costs 1 credit per call.", pricesPerSqfSchema, endpointHandler(pricesPerSqf), ); lifelineTool(server, "property-data-sold-prices-per-sqf", "Land Registry sold £/sqft distribution for a postcode — avg plus percentile ranges, up to 20 raw sales with square-foot data and a sales-window date range. Use for the achieved £/sqft KPI. Costs 1 credit per call.", { postcode: z.string().min(2).describe("UK postcode (outcode or full)"), type: z .enum(["detached", "semi-detached", "terraced", "flat"]) .optional() .describe("Filter by property type"), bedrooms: z.number().int().min(0).optional().describe("Filter by bedroom count"), max_age: z.number().int().min(1).optional().describe("Max transaction age in months"), }, endpointHandler(soldPricesPerSqf), ); lifelineTool(server, "property-data-growth-psf", "7-year £/sqft series with year-on-year percent change for a postcode. Use for the £/sqft growth chart on a market report. Costs 1 credit per call.", { postcode: z.string().min(2).describe("UK postcode (outcode or full)"), type: z .enum(["detached", "semi-detached", "terraced", "flat"]) .optional() .describe("Filter by property type"), }, endpointHandler(growthPsf), ); // ───────────────────────────────────────────────────────────────── // Task 144 — Area risk and cost of occupancy // ───────────────────────────────────────────────────────────────── lifelineTool(server, "property-data-crime", "Recent crime statistics for a postcode (or location / town) — per-1,000 rate, 12-month total, breakdown by crime type, and per-month observations. Use for the area-risk pillar. Costs 1 credit per call.", { postcode: z.string().min(2).optional().describe("UK postcode (outcode or full)"), location: z .string() .optional() .describe("Latitude,longitude pair (e.g. '51.5074,-0.1278'). Alternative to postcode."), town: z.string().optional().describe("UK town name. Alternative to postcode."), }, endpointHandler(crime), ); lifelineTool(server, "property-data-flood-risk", "River and sea flood-risk rating for a full UK postcode. Use for area-risk reporting and lender-ready disclosures. Requires a full postcode (not outcode). Costs 1 credit per call.", { postcode: z.string().min(5).describe("Full UK postcode (not outcode)"), }, endpointHandler(floodRisk), ); lifelineTool(server, "property-data-council-tax", "Council-tax authority, rating, and per-band charges (A–H) for a full UK postcode, plus any per-property bands the API returns. Use for cost-of-occupancy disclosures. Requires a full postcode. Costs 1 credit per call.", { postcode: z.string().min(5).describe("Full UK postcode (not outcode)"), }, endpointHandler(councilTax), ); // ───────────────────────────────────────────────────────────────── // Task 144 — Planning precedent and address resolution // ───────────────────────────────────────────────────────────────── lifelineTool(server, "property-data-planning-applications", "Planning applications within a radius of a postcode — references, proposals, decisions, appeals, distance. Use for development precedent and pre-purchase risk. Costs 2 credits per call. Throttled to ~4 calls per 10 seconds upstream — wrap in the preval skill for fan-out, do not loop directly.", { postcode: z.string().min(2).describe("UK postcode (outcode or full)"), decision_rating: z .enum(["positive", "neutral", "negative"]) .optional() .describe("Filter by decision sentiment"), category: z.string().optional().describe("PropertyData planning category filter"), max_age_decision: z .number() .int() .min(1) .optional() .describe("Max age of decision in months"), results: z.number().int().min(1).max(100).optional().describe("Max results to return"), }, endpointHandler(planningApplications), ); lifelineTool(server, "property-data-address-match-uprn", "Resolve a full UK address string to its UPRN, address classification, and lat/lng. Use for CRM hygiene and unique-property identification. Costs 10 credits per call — do not fan out blindly; call once per address you actually need to resolve.", { address: z.string().min(5).describe("Full UK address (line one + postcode)"), }, endpointHandler(addressMatchUprn), ); // ───────────────────────────────────────────────────────────────── // Task 144 — Rental demand and property-type mix // ───────────────────────────────────────────────────────────────── lifelineTool(server, "property-data-demand-rent", "Rental demand signals for a postcode — for-rent count, lets per month, turnover percent, months of inventory, days on market, and a rating. Use for the rental pillar on a market report. Costs 1 credit per call.", { postcode: z.string().min(2).describe("UK postcode (outcode or full)"), bedrooms: z.number().int().min(0).optional().describe("Filter by bedroom count"), type: z .enum(["detached", "semi-detached", "terraced", "flat"]) .optional() .describe("Filter by property type"), }, endpointHandler(demandRent), ); lifelineTool(server, "property-data-property-types", "Output-area property-type distribution (percent by class — detached, semi-detached, terraced, flat) for a postcode. Use for the property-type mix doughnut on a market report. Costs 1 credit per call.", { postcode: z.string().min(2).describe("UK postcode (outcode or full)"), }, endpointHandler(propertyTypes), ); // ───────────────────────────────────────────────────────────────── // Lifecycle // ───────────────────────────────────────────────────────────────── process.on("SIGINT", async () => { await closeDriver(); process.exit(0); }); const transport = new StdioServerTransport(); await server.connect(transport);