// Logging-tee was a platform-wrapper concern; the host owns the stderr pipe // in both platform and standalone (Claude Code native) spawn paths. 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 { propertySearch } from "./tools/property-search.js"; import { propertyDetail } from "./tools/property-detail.js"; import { propertyListed } from "./tools/property-listed.js"; import { propertySold } from "./tools/property-sold.js"; import { propertyViewing, propertyCallback, propertyInformation } from "./tools/property-intent.js"; import { peopleSearch } from "./tools/people-search.js"; import { peopleDetail } from "./tools/people-detail.js"; import { viewingSearch } from "./tools/viewing-search.js"; import { viewingDetail } from "./tools/viewing-detail.js"; import { viewingCreate } from "./tools/viewing-create.js"; import { viewingUpdate } from "./tools/viewing-update.js"; import { feedbackGet, feedbackSubmit } from "./tools/feedback.js"; import { teamInfo } from "./tools/team-info.js"; import { teamAvailability } from "./tools/team-availability.js"; import { marketingMatchDetail } from "./tools/marketing-match.js"; import { marketingMatchBatch } from "./tools/marketing-match-batch.js"; import { marketingMatchRequest } from "./tools/marketing-match-request.js"; import { marketingEnquiry } from "./tools/marketing-enquiry.js"; import { autoResponder } from "./tools/auto-responder.js"; import { customerPreferences } from "./tools/customer-preferences.js"; import { supplier } from "./tools/supplier.js"; import { lifelineTool } from "../../../../../../platform/lib/mcp-lifeline/dist/index.js"; import { closeDriver } from "./lib/neo4j.js"; import { selectLoopKeyStore } from "./lib/key-store.js"; // ACCOUNT_ID is the platform's tenant id; "local" is the single-tenant // standalone stand-in when spawned natively by Claude Code. const accountId = process.env.ACCOUNT_ID ?? "local"; const server = new McpServer({ name: "maxy-loop", version: "0.2.0", }); const ALL_PERMISSIONS = z.enum([ "properties", "people", "viewings", "feedback", "team", "marketing", "customer", "supplier", ]); const store = selectLoopKeyStore(); console.error( `[loop] server started, account=${accountId} backend=${store.backendName} path=${store.backendPath}`, ); // Helper: wrap a tool function in standard error handling function toolHandler(fn: (p: Record) => Promise) { return async (params: Record) => { try { const text = await fn({ ...params, accountId }); return { content: [{ type: "text" as const, text }] }; } catch (err) { return { content: [{ type: "text" as const, text: `Error: ${err instanceof Error ? err.message : String(err)}`, }], isError: true, }; } }; } // ───────────────────────────────────────────────────────────────── // Key Management Tools (3) // ───────────────────────────────────────────────────────────────── lifelineTool(server, "loop-key-register", "Register a Loop CRM API key for a team. Validates the key against Loop's API, encrypts it, and stores it in the graph. Each key maps to one team.", { teamName: z.string().min(1).describe("Human-readable name for this team (e.g. 'Muvin Main Office')"), apiKey: z.string().min(10).describe("Loop API key (X-Api-Key header value)"), permissions: z .array(ALL_PERMISSIONS) .optional() .describe("Endpoint groups this key can access (default: all 8 groups)"), }, async (params) => { try { const result = await keyRegister({ ...params, accountId }); let text = `Team "${params.teamName}" registered successfully.`; if (result.warning) text += `\n\nWarning: ${result.warning}`; return { content: [{ type: "text" as const, text }] }; } catch (err) { return { content: [{ type: "text" as const, text: `Failed to register key: ${err instanceof Error ? err.message : String(err)}`, }], isError: true, }; } } ); lifelineTool(server, "loop-key-deregister", "Remove a registered Loop CRM team key. The key is permanently deleted from the graph.", { teamName: z.string().min(1).describe("Name of the team to remove") }, async ({ teamName }) => { try { await keyDeregister({ teamName, accountId }); return { content: [{ type: "text" as const, text: `Team "${teamName}" deregistered.` }] }; } catch (err) { return { content: [{ type: "text" as const, text: `Failed to deregister: ${err instanceof Error ? err.message : String(err)}`, }], isError: true, }; } } ); lifelineTool(server, "loop-key-list", "List all registered Loop CRM teams for this account. Shows team names, addresses, and permissions. Never reveals API key values.", {}, toolHandler(async (p) => keyList({ accountId: p.accountId as string })) ); // ───────────────────────────────────────────────────────────────── // People Tools (2) — permission: people // ───────────────────────────────────────────────────────────────── lifelineTool(server, "loop-people-search", "Search people in Loop CRM. Without a role, searches all contacts. With a role (buyers/sellers/renters/landlords), returns role-specific results with rich filters.", { role: z.enum(["buyers", "sellers", "renters", "landlords"]).optional().describe("Role to search (omit for all people)"), searchTerm: z.string().optional().describe("Name or contact search"), maxPrice: z.number().optional().describe("Max price (buyers) or max price filter (sellers)"), minPrice: z.number().optional().describe("Min price (sellers)"), minBeds: z.number().optional().describe("Min bedrooms (buyers/renters)"), maxRent: z.number().optional().describe("Max rent (renters only)"), searchAreas: z.string().optional().describe("Comma-separated outcodes e.g. 'SN7,OX12' (buyers/renters)"), propertyTypes: z.string().optional().describe("Comma-separated property types (buyers/renters)"), startDate: z.string().optional().describe("Start date filter (sellers/landlords)"), endDate: z.string().optional().describe("End date filter (sellers/landlords)"), teamName: z.string().optional().describe("Query a specific team only"), limit: z.number().int().optional().describe("Max results (default 200)"), }, toolHandler(async (p) => peopleSearch(p as Parameters[0])) ); lifelineTool(server, "loop-people-detail", "Get full details for a specific person by ID. Optionally specify a role (buyers/sellers/renters/landlords) for role-specific detail.", { personId: z.number().int().describe("Person ID"), role: z.enum(["buyers", "sellers", "renters", "landlords"]).optional().describe("Role for role-specific detail view"), teamName: z.string().optional().describe("Query a specific team only"), }, toolHandler(async (p) => peopleDetail(p as Parameters[0])) ); // ───────────────────────────────────────────────────────────────── // Property Tools (4) — permission: properties // ───────────────────────────────────────────────────────────────── lifelineTool(server, "loop-property-search", "Search properties in Loop CRM. Queries /property/residential/sales and /property/residential/lettings. Use department to narrow to one type.", { department: z.enum(["sales", "lettings", "both"]).optional().describe("Department (default: both)"), searchTerm: z.string().optional().describe("Address or keyword search"), minPrice: z.number().optional().describe("Minimum price"), maxPrice: z.number().optional().describe("Maximum price"), minBedrooms: z.number().int().optional().describe("Minimum bedrooms"), maxBedrooms: z.number().int().optional().describe("Maximum bedrooms"), propertyStatuses: z.string().optional().describe("Comma-separated statuses (e.g. 'forSale,underOffer')"), propertyTypes: z.string().optional().describe("Comma-separated property types"), teamName: z.string().optional().describe("Query a specific team only"), limit: z.number().int().optional().describe("Max results (default 200)"), }, toolHandler(async (p) => propertySearch(p as Parameters[0])) ); lifelineTool(server, "loop-property-detail", "Get full details for a specific property by ID and department. Optionally include a preview hash for the public preview.", { propertyId: z.number().int().describe("Property ID"), department: z.enum(["sales", "lettings"]).describe("sales or lettings"), previewHash: z.number().int().optional().describe("Preview hash for public preview URL"), teamName: z.string().optional().describe("Query a specific team only"), }, toolHandler(async (p) => propertyDetail(p as Parameters[0])) ); lifelineTool(server, "loop-property-listed", "Get properties currently listed on a specific channel (Rightmove, Zoopla, OnTheMarket, website). For exchanged/completed properties use loop-property-sold.", { channel: z.enum(["rightmove", "onTheMarket", "zoopla", "website"]).describe("Listing channel"), department: z.enum(["sales", "lettings", "both"]).optional().describe("Department (default: both)"), teamName: z.string().optional().describe("Query a specific team only"), limit: z.number().int().optional().describe("Max results (default 200)"), }, toolHandler(async (p) => propertyListed(p as Parameters[0])) ); lifelineTool(server, "loop-property-sold", "Get exchanged or completed properties on a specific channel. Powers canvassing from recent sales and sold-gallery rendering.", { channel: z.enum(["rightmove", "onTheMarket", "zoopla", "website"]).describe("Listing channel"), teamName: z.string().optional().describe("Query a specific team only"), limit: z.number().int().optional().describe("Max results (default 200)"), }, toolHandler(async (p) => propertySold(p as Parameters[0])) ); // Property-level intent tools (Task 103 split — replaces loop-property-request). // Each intent is a distinct operator action so the agent's tool surface // preserves intent fidelity. lifelineTool(server, "loop-property-viewing", "Request a viewing for a property. Use when the operator says 'book a viewing', 'arrange a viewing', or 'ask to see the property'.", { teamName: z.string().min(1).describe("Team to submit the request through"), propertyId: z.number().int().describe("Property ID"), department: z.enum(["sales", "lettings"]).describe("sales or lettings"), name: z.string().optional().describe("Requester name"), email: z.string().optional().describe("Requester email"), phone: z.string().optional().describe("Requester phone"), message: z.string().optional().describe("Additional message"), }, toolHandler(async (p) => propertyViewing(p as Parameters[0])) ); lifelineTool(server, "loop-property-callback", "Request a callback about a property. Use when the operator says 'ask them to call', 'request a callback', or 'have someone ring back'.", { teamName: z.string().min(1).describe("Team to submit the request through"), propertyId: z.number().int().describe("Property ID"), department: z.enum(["sales", "lettings"]).describe("sales or lettings"), name: z.string().optional().describe("Requester name"), email: z.string().optional().describe("Requester email"), phone: z.string().optional().describe("Requester phone"), message: z.string().optional().describe("Additional message"), }, toolHandler(async (p) => propertyCallback(p as Parameters[0])) ); lifelineTool(server, "loop-property-information", "Request more information about a property. Use when the operator says 'ask for details', 'request the brochure', or 'find out more'.", { teamName: z.string().min(1).describe("Team to submit the request through"), propertyId: z.number().int().describe("Property ID"), department: z.enum(["sales", "lettings"]).describe("sales or lettings"), name: z.string().optional().describe("Requester name"), email: z.string().optional().describe("Requester email"), phone: z.string().optional().describe("Requester phone"), message: z.string().optional().describe("Additional message"), }, toolHandler(async (p) => propertyInformation(p as Parameters[0])) ); // ───────────────────────────────────────────────────────────────── // Viewing Tools (4) — permission: viewings // ───────────────────────────────────────────────────────────────── lifelineTool(server, "loop-viewing-search", "Search viewings in Loop CRM. Queries /residential/sales/viewings and /residential/lettings/viewings.", { department: z.enum(["sales", "lettings", "both"]).optional().describe("Department (default: both)"), searchTerm: z.string().optional().describe("Search viewings"), appointmentStartDate: z.string().optional().describe("Filter from date (ISO format)"), appointmentEndDate: z.string().optional().describe("Filter to date (ISO format)"), status: z.string().optional().describe("Viewing status filter"), teamName: z.string().optional().describe("Query a specific team only"), limit: z.number().int().optional().describe("Max results (default 200)"), }, toolHandler(async (p) => viewingSearch(p as Parameters[0])) ); lifelineTool(server, "loop-viewing-detail", "Get full details for a specific viewing by ID and department.", { viewingId: z.number().int().describe("Viewing ID"), department: z.enum(["sales", "lettings"]).describe("sales or lettings"), teamName: z.string().optional().describe("Query a specific team only"), }, toolHandler(async (p) => viewingDetail(p as Parameters[0])) ); lifelineTool(server, "loop-viewing-create", "Create a new viewing in Loop CRM. Creates the viewing and associated buyer/renter record.", { teamName: z.string().min(1).describe("Team to create the viewing for"), department: z.enum(["sales", "lettings"]).describe("sales or lettings"), propertyId: z.number().int().describe("Property ID"), date: z.string().describe("Viewing date (YYYY-MM-DD)"), time: z.string().describe("Viewing time (HH:mm)"), attendeeName: z.string().describe("Attendee full name"), attendeeEmail: z.string().optional().describe("Attendee email"), attendeePhone: z.string().optional().describe("Attendee phone"), }, toolHandler(async (p) => viewingCreate(p as Parameters[0])) ); lifelineTool(server, "loop-viewing-update", "Add a note or record feedback for a viewing. For feedback, specify the party: buyer/seller (sales) or renter/landlord (lettings).", { teamName: z.string().min(1).describe("Team that owns the viewing"), viewingId: z.number().int().describe("Viewing ID"), department: z.enum(["sales", "lettings"]).describe("sales or lettings"), action: z.enum(["note", "feedback"]).describe("note or feedback"), text: z.string().describe("Note text or feedback content"), feedbackParty: z.enum(["buyer", "seller", "renter", "landlord"]).optional().describe("Required for feedback: whose feedback"), }, toolHandler(async (p) => viewingUpdate(p as Parameters[0])) ); // ───────────────────────────────────────────────────────────────── // Feedback Tools (2) — permission: feedback // ───────────────────────────────────────────────────────────────── lifelineTool(server, "loop-feedback-get", "Get feedback for a specific viewing. Reads from /feedback/residential/{department}/viewings/{id}.", { teamName: z.string().min(1).describe("Team that owns the viewing"), viewingId: z.number().int().describe("Viewing ID"), department: z.enum(["sales", "lettings"]).describe("sales or lettings"), }, toolHandler(async (p) => feedbackGet(p as Parameters[0])) ); lifelineTool(server, "loop-feedback-submit", "Submit feedback for a viewing. Writes to /feedback/residential/{department}/viewings/{id}/feedback.", { teamName: z.string().min(1).describe("Team that owns the viewing"), viewingId: z.number().int().describe("Viewing ID"), department: z.enum(["sales", "lettings"]).describe("sales or lettings"), feedback: z.string().describe("Feedback text"), }, toolHandler(async (p) => feedbackSubmit(p as Parameters[0])) ); // ───────────────────────────────────────────────────────────────── // Team Tools (2) — permission: team // ───────────────────────────────────────────────────────────────── lifelineTool(server, "loop-team-info", "Get team details from Loop CRM. Returns team name, address, phone, and email.", { teamName: z.string().optional().describe("Query a specific team only (omit for all teams)"), }, toolHandler(async (p) => teamInfo(p as Parameters[0])) ); lifelineTool(server, "loop-team-availability", "Get available time slots for a specific agent on a given date.", { agentId: z.string().describe("Agent GUID identifier"), searchDate: z.string().describe("Date to check availability (YYYY-MM-DD)"), teamName: z.string().optional().describe("Query a specific team only"), }, toolHandler(async (p) => teamAvailability(p as Parameters[0])) ); // ───────────────────────────────────────────────────────────────── // Marketing Tools (4) — permission: marketing // ───────────────────────────────────────────────────────────────── lifelineTool(server, "loop-marketing-match", "Get matching property detail and optionally the team profile for that match.", { propertyId: z.number().int().describe("Property ID"), department: z.enum(["sales", "lettings"]).describe("sales or lettings"), includeTeamProfile: z.boolean().optional().describe("Include the team profile for this match (default: false)"), teamName: z.string().optional().describe("Query a specific team only"), }, toolHandler(async (p) => marketingMatchDetail(p as Parameters[0])) ); lifelineTool(server, "loop-marketing-match-batch", "Get batch matching results for multiple property IDs.", { propertyIds: z.array(z.number().int()).describe("Array of property IDs to match"), department: z.enum(["sales", "lettings"]).describe("sales or lettings"), teamName: z.string().optional().describe("Query a specific team only"), }, toolHandler(async (p) => marketingMatchBatch(p as Parameters[0])) ); lifelineTool(server, "loop-marketing-match-request", "Submit a viewing, information, or callback request for a matching property.", { teamName: z.string().min(1).describe("Team to submit the request through"), propertyId: z.number().int().describe("Matching property ID"), department: z.enum(["sales", "lettings"]).describe("sales or lettings"), action: z.enum(["viewing", "information", "callback"]).describe("Request type"), name: z.string().optional().describe("Requester name"), email: z.string().optional().describe("Requester email"), phone: z.string().optional().describe("Requester phone"), message: z.string().optional().describe("Additional message"), }, toolHandler(async (p) => marketingMatchRequest(p as Parameters[0])) ); lifelineTool(server, "loop-marketing-enquiry", "Submit a seller-side marketing enquiry to Loop. For inbound applicant auto-responder workflows use loop-auto-responder.", { teamName: z.string().min(1).describe("Team to submit through"), sellerEnquiryData: z.record(z.string(), z.unknown()).optional().describe("Seller enquiry request body"), }, toolHandler(async (p) => marketingEnquiry(p as Parameters[0])) ); lifelineTool(server, "loop-auto-responder", "Read or progress an inbound enquiry through Loop's auto-responder (the surface behind Loop's 40% enquiry-efficiency claim). action=get reads questions+details; answers submits applicant replies; details updates the enquiry record; refer hands it to another team.", { teamName: z.string().min(1).describe("Team for this operation"), action: z.enum(["get", "answers", "details", "refer"]).describe("Auto-responder action"), autoResponderId: z.number().int().describe("Auto-responder enquiry ID"), autoResponderKey: z.string().describe("Auto-responder key (UUID issued at enquiry creation)"), answers: z.array(z.unknown()).optional().describe("Applicant answers (required for action=answers)"), details: z.record(z.string(), z.unknown()).optional().describe("Enquiry details (for action=details)"), }, toolHandler(async (p) => autoResponder(p as Parameters[0])) ); // ───────────────────────────────────────────────────────────────── // Customer Tools (1) — permission: customer // ───────────────────────────────────────────────────────────────── lifelineTool(server, "loop-customer-preferences", "Read or write customer preferences for a person. Action 'read' returns current preferences, 'write' updates them.", { teamName: z.string().min(1).describe("Team for this operation"), personCode: z.number().int().describe("Person code (ID)"), action: z.enum(["read", "write"]).describe("read or write"), preferences: z.record(z.string(), z.unknown()).optional().describe("Preferences object (required for write)"), }, toolHandler(async (p) => customerPreferences(p as Parameters[0])) ); // ───────────────────────────────────────────────────────────────── // Supplier Tools (1) — permission: supplier // ───────────────────────────────────────────────────────────────── lifelineTool(server, "loop-supplier", "Supplier operations: maintenance jobs, quotes, board contractor jobs. Supports listing, completing, and quoting.", { teamName: z.string().min(1).describe("Team for this operation"), action: z.enum([ "maintenance-jobs", "maintenance-complete", "maintenance-quotes", "maintenance-submit-quote", "board-jobs", "board-complete", ]).describe("Supplier action"), code: z.string().describe("Unique code assigned to the contractor"), jobId: z.number().int().optional().describe("Job ID (required for all actions except maintenance-submit-quote)"), quoteId: z.number().int().optional().describe("Quote ID (for maintenance-submit-quote)"), quoteData: z.record(z.string(), z.unknown()).optional().describe("Quote data (for maintenance-submit-quote)"), completionData: z.record(z.string(), z.unknown()).optional().describe("Completion data (for board-complete)"), }, toolHandler(async (p) => supplier(p as Parameters[0])) ); // ───────────────────────────────────────────────────────────────── // Lifecycle // ───────────────────────────────────────────────────────────────── process.on("SIGINT", async () => { await closeDriver(); process.exit(0); }); const transport = new StdioServerTransport(); await server.connect(transport);