// 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);