/** Public catalogue tools only. This client never reads private property data. */ export type Household = "single" | "small_hmo" | "large_hmo"; export type Link = { title: string; url: string }; export type RuleStatus = "in_force" | "confirmed_future" | "proposed"; export type RuleSummary = { id: string; title: string; status: RuleStatus; summary: string; url: string }; export type Rule = RuleSummary & { category: string; requirement: string; applies_when: string; legal_basis: Link[]; guidance: Link[]; confidence: "high" | "medium" | "low"; effective_from: string | null; effective_to: string | null; [field: string]: unknown; }; export type RegisterWindow = { region: string; opens: string; deadline: string; earlyRegistrationFrom: string; feeGbp: number }; export type Scheme = { name: string; type: "selective" | "additional" | "mandatory_hmo"; status: "in_force" | "approved_not_started" | "consultation" | "expired" | "quashed" | "revoked"; source_urls: string[]; confidence: "high" | "medium" | "low"; [field: string]: unknown; }; export type CouncilLicensing = | { researched: false; council: { name: string; slug: string; region: string; gss: string }; message: string; register: RegisterWindow | null; registerSources: string[]; registerCheckedAt: string } | { researched: true; authority: string; slug: string; gss: string; researched_at: string; verified_at: string | null; schemes: Scheme[]; open_questions: string[]; url: string; [field: string]: unknown }; export type AddressCheck = { postcode: string; ward: string | null; council: string | null; household: Household; licence: { needed: "yes" | "no" | "check"; summary: string; schemes: { name: string; type: Scheme["type"]; status: Scheme["status"]; covers: "yes" | "check"; reason: string; start: string | null; end: string | null; feeGbp: number | null; confidence: Scheme["confidence"]; notes: string | null; apply: string | null; sources: string[] }[] }; register: RegisterWindow | null; registerSources: string[]; registerCheckedAt: string; planningSource: string; planningAvailability: Record<"floodZone" | "conservationAreas" | "article4", "available" | "unavailable">; openQuestions: string[]; floodZone: { level: 2 | 3; source: string } | null; conservationAreas: string[] | null; article4Directions: { name: string; description: string; url: string | null }[] | null; councilPage: string | null; researched: string | null; verified: string | null; limitations: string[]; }; export type ToolInfo = { name: string; title?: string; description?: string; inputSchema: Record; annotations?: Record }; export class LettanceError extends Error { constructor(message: string, readonly kind: "http" | "rpc" | "tool" | "protocol", readonly status?: number, readonly retryAfter?: string | null) { super(message); this.name = "LettanceError"; } } export type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise; export type ClientOptions = { endpoint?: string; fetch?: Fetcher; timeoutMs?: number }; function object(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** A small JSON-response client for this stateless endpoint; use a general MCP SDK for streaming servers. */ export class LettanceClient { private nextId = 0; private ready?: Promise; private protocol = "2025-11-25"; private readonly endpoint: string; private readonly fetcher: Fetcher; private readonly timeout: number; constructor(options: ClientOptions = {}) { this.endpoint = options.endpoint ?? "https://lettance.com/mcp"; this.fetcher = options.fetch ?? globalThis.fetch; this.timeout = options.timeoutMs ?? 25_000; if (!Number.isFinite(this.timeout) || this.timeout <= 0) throw new TypeError("timeoutMs must be positive"); } private async send(method: string, params?: Record, notification = false): Promise { const id = ++this.nextId; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeout); try { const response = await this.fetcher(this.endpoint, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...(method === "initialize" ? {} : { "MCP-Protocol-Version": this.protocol }) }, body: JSON.stringify({ jsonrpc: "2.0", ...(notification ? {} : { id }), method, ...(params ? { params } : {}) }), signal: controller.signal, credentials: "omit", }); if (!response.ok) throw new LettanceError(`Lettance returned HTTP ${response.status}`, "http", response.status, response.headers.get("retry-after")); if (notification) { if (response.status !== 202) throw new LettanceError("Expected an empty 202 notification acknowledgement", "protocol"); return; } if (!response.headers.get("content-type")?.includes("application/json")) throw new LettanceError("Expected a JSON response from the Lettance endpoint", "protocol"); const message: unknown = await response.json(); if (!object(message) || message.jsonrpc !== "2.0" || message.id !== id) throw new LettanceError("Invalid or mismatched JSON-RPC response", "protocol"); if (object(message.error)) throw new LettanceError(typeof message.error.message === "string" ? message.error.message : "MCP request failed", "rpc"); if (!("result" in message)) throw new LettanceError("Missing JSON-RPC result", "protocol"); return message.result; } finally { clearTimeout(timer); } } private initialize(): Promise { if (!this.ready) { this.ready = (async () => { const result = await this.send("initialize", { protocolVersion: this.protocol, capabilities: {}, clientInfo: { name: "lettance-typescript", version: "0.1.0" } }); if (!object(result) || !["2025-11-25", "2025-06-18", "2025-03-26"].includes(String(result.protocolVersion))) throw new LettanceError("Unsupported negotiated protocol", "protocol"); this.protocol = result.protocolVersion as string; await this.send("notifications/initialized", undefined, true); })().catch((error) => { this.ready = undefined; throw error; }); } return this.ready; } async listTools(): Promise { await this.initialize(); const result = await this.send("tools/list"); if (!object(result) || !Array.isArray(result.tools)) throw new LettanceError("Invalid tools list", "protocol"); return result.tools as ToolInfo[]; } private async tool(name: string, args: Record, valid: (value: Record) => boolean): Promise { await this.initialize(); const result = await this.send("tools/call", { name, arguments: args }); if (!object(result)) throw new LettanceError("Invalid tool response", "protocol"); const texts = Array.isArray(result.content) ? result.content.filter((part): part is Record => object(part) && part.type === "text") : []; const text = texts.map((part) => String(part.text ?? "")).join("\n"); if (result.isError) throw new LettanceError(text || "Lookup failed", "tool"); let value = result.structuredContent; if (!object(value)) { try { value = JSON.parse(text); } catch { throw new LettanceError("Tool did not return structured data", "protocol"); } } if (!object(value) || !valid(value)) throw new LettanceError("Unexpected tool result shape", "protocol"); return value as T; } searchRules(query: string): Promise<{ results: RuleSummary[] }> { return this.tool("search_rules", { query }, (v) => Array.isArray(v.results)); } getRule(id: string): Promise { return this.tool("get_rule", { id }, (v) => typeof v.id === "string" && Array.isArray(v.legal_basis)); } councilLicensing(council: string): Promise { return this.tool("council_licensing", { council }, (v) => v.researched === true ? Array.isArray(v.schemes) && typeof v.authority === "string" : v.researched === false && object(v.council)); } checkAddress(postcode: string, household: Household = "single"): Promise { return this.tool("check_address", { postcode, household }, (v) => typeof v.postcode === "string" && object(v.licence) && ["yes", "no", "check"].includes(String(v.licence.needed))); } }