/* * Copyright (c) 2026 by Christian Kellner. * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ /* * Copyright (c) 2026 by Christian Kellner. * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { queryJobs, getJob } from '../services/storage/jobStorage.js'; import { queryListings, getListingById } from '../services/storage/listingsStorage.js'; import { authenticateToolCall, checkJobAccess } from './mcpAuthentication.js'; import { normalizeListJobs, normalizeGetJob, normalizeListListings, normalizeGetListing, normalizeError, } from './mcpNormalizer.js'; /** * Create a configured MCP server instance with all Fredy tools registered. * * The adapter fetches raw data from storage and delegates response formatting * to the normalizer layer (mcpNormalizer.js) which produces a consistent * { ok, summary, data, meta } envelope for every tool response. * * Each tool call requires a userId (resolved from the MCP token before invocation). * Tools respect user scoping: non-admin users only see their own jobs/listings. * * @returns {McpServer} */ export function createMcpServer() { const server = new McpServer( { name: 'fredy-mcp', version: '1.0.0', }, { capabilities: { tools: {}, }, instructions: 'Fredy MCP Server – query real estate jobs and listings. ' + 'All timestamps are unix timestamps in milliseconds (e.g. 1772008362564). ' + 'Use list_jobs to browse jobs, get_job for details, ' + 'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' + 'and get_listing for full details of a single listing. ' + 'Responses are formatted as markdown with a summary, data (tables for lists, key-value for details), and pagination info. ' + 'Always present results to the user as soon as you have them - do NOT call the tool again unless you need additional pages or different data.', }, ); // ── list_jobs ─────────────────────────────────────────────────────── server.tool( 'list_jobs', 'List real estate search jobs for the authenticated user. ' + 'Returns up to 50 jobs per page by default. Use pagination (page parameter) to fetch more. ' + 'Check meta.hasMore to know if there are additional pages.', { page: z.number().optional().describe('Page number (default: 1)'), pageSize: z .number() .optional() .describe('Results per page (default: 50, max: 1000). Start with the default and paginate if needed.'), filter: z.string().optional().describe('Free-text filter on job name'), }, async ({ page, pageSize, filter }, extra) => { const { user, error } = authenticateToolCall(extra, 'list_jobs'); if (error) return normalizeError(error, 'list_jobs'); const safePage = page ?? 1; const safePageSize = pageSize ?? 50; const result = queryJobs({ page: safePage, pageSize: safePageSize, freeTextFilter: filter, userId: user.id, isAdmin: user.isAdmin, }); return normalizeListJobs(result, { page: safePage, pageSize: safePageSize }); }, ); // ── get_job ───────────────────────────────────────────────────────── server.tool( 'get_job', 'Get detailed information about a specific job by its ID.', { jobId: z.string().describe('The job ID to retrieve'), }, async ({ jobId }, extra) => { const { user, error } = authenticateToolCall(extra, 'get_job'); if (error) return normalizeError(error, 'get_job'); const job = getJob(jobId); if (!job) { return normalizeError('Job not found.', 'get_job'); } if (!checkJobAccess(user, job)) { return normalizeError('Access denied.', 'get_job'); } return normalizeGetJob(job); }, ); // ── list_listings ─────────────────────────────────────────────────── server.tool( 'list_listings', 'Search and list real estate listings. Returns up to 50 listings per page by default. ' + 'Use pagination (page parameter) to fetch more. Check meta.hasMore in the response. ' + 'Supports text search, time filtering, and various filters. ' + 'All timestamps are unix timestamps in milliseconds (e.g. 1772008362564). ' + 'Use createdAfter/createdBefore to filter by time, e.g. "give me all listings from today". ' + 'Use get_listing to get full details (description, link, image) for a specific listing.', { page: z.number().optional().describe('Page number (default: 1)'), pageSize: z .number() .optional() .describe('Results per page (default: 50, max: 1000). Start with the default and paginate if needed.'), filter: z.string().optional().describe('Free-text search across title, address, provider, link'), jobId: z.string().optional().describe('Filter listings by job ID'), activeOnly: z.boolean().optional().describe('When true, only show active listings'), provider: z.string().optional().describe('Filter by provider name'), createdAfter: z .number() .optional() .describe( 'Only include listings created at or after this unix timestamp in milliseconds (e.g. 1772008362564). Useful for queries like "listings from today".', ), createdBefore: z .number() .optional() .describe( 'Only include listings created at or before this unix timestamp in milliseconds (e.g. 1772008362564).', ), minPrice: z .number() .optional() .describe( 'Only include listings with price >= this value (e.g. 500). Price is a numeric value (no currency symbol).', ), maxPrice: z .number() .optional() .describe( 'Only include listings with price <= this value (e.g. 1500). Price is a numeric value (no currency symbol).', ), sortField: z.string().optional().describe('Sort by: created_at, price, size, provider, title, is_active'), sortDir: z.string().optional().describe('Sort direction: asc or desc'), status: z .enum(['applied', 'rejected', 'accepted', 'none']) .optional() .describe( 'Filter by user-set status. "applied", "rejected", or "accepted" return only listings with that status; "none" returns only listings without a status set.', ), }, async ( { page, pageSize, filter, jobId, activeOnly, provider, createdAfter, createdBefore, minPrice, maxPrice, sortField, sortDir, status, }, extra, ) => { const { user, error } = authenticateToolCall(extra, 'list_listings'); if (error) return normalizeError(error, 'list_listings'); const safePage = page ?? 1; const safePageSize = pageSize ?? 50; const result = queryListings({ page: safePage, pageSize: safePageSize, freeTextFilter: filter, jobIdFilter: jobId, activityFilter: activeOnly === true ? true : activeOnly === false ? false : undefined, providerFilter: provider, createdAfter: createdAfter ?? null, createdBefore: createdBefore ?? null, minPrice: minPrice ?? null, maxPrice: maxPrice ?? null, sortField: sortField ?? null, sortDir: sortDir ?? 'desc', statusFilter: status, userId: user.id, isAdmin: user.isAdmin, }); return normalizeListListings(result, { page: safePage, pageSize: safePageSize }); }, ); // ── get_listing ───────────────────────────────────────────────────── server.tool( 'get_listing', 'Get full details of a single listing by its ID.', { listingId: z.string().describe('The listing ID to retrieve'), }, async ({ listingId }, extra) => { const { user, error } = authenticateToolCall(extra, 'get_listing'); if (error) return normalizeError(error, 'get_listing'); const listing = getListingById(listingId, user.id, user.isAdmin); if (!listing) { return normalizeError('Listing not found or access denied.', 'get_listing'); } return normalizeGetListing(listing); }, ); // ── get_photo_for_listing ───────────────────────────────────────────────────── server.tool( 'get_photo_for_listing', 'Fetch and return the photo of a listing by its ID as an image for vision analysis.', { listingId: z.string().describe('The listing ID whose photo to fetch'), }, async ({ listingId }, extra) => { const { user, error } = authenticateToolCall(extra, 'get_photo_for_listing'); if (error) return normalizeError(error, 'get_photo_for_listing'); const listing = getListingById(listingId, user.id, user.isAdmin); if (!listing) { return normalizeError('Listing not found or access denied.', 'get_photo_for_listing'); } const imageUrl = listing.image_url; if (!imageUrl) { return normalizeError('No image available for this listing.', 'get_photo_for_listing'); } const SUPPORTED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); let response; try { response = await fetch(imageUrl, { signal: AbortSignal.timeout(10_000), headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', Accept: 'image/jpeg,image/png,image/webp,image/gif,image/*,*/*', }, }); } catch (fetchErr) { return normalizeError(`Failed to fetch image: ${fetchErr.message}`, 'get_photo_for_listing'); } if (!response.ok) { return normalizeError( `Image fetch returned HTTP ${response.status}. Image URL: ${imageUrl}`, 'get_photo_for_listing', ); } const contentType = response.headers.get('content-type') ?? ''; const headerMimeType = contentType.split(';')[0].trim().toLowerCase(); let buffer; try { buffer = await response.arrayBuffer(); } catch (readErr) { return normalizeError(`Failed to read image body: ${readErr.message}`, 'get_photo_for_listing'); } const bytes = new Uint8Array(buffer); if (bytes.length < 12) { return normalizeError( `Downloaded file is too small to determine image type. Image URL: ${imageUrl}`, 'get_photo_for_listing', ); } let resolvedMime; if (SUPPORTED_MIME_TYPES.has(headerMimeType)) { resolvedMime = headerMimeType; } else { if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { resolvedMime = 'image/jpeg'; } else if ( bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a ) { resolvedMime = 'image/png'; } else if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) { resolvedMime = 'image/gif'; } else if ( bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 ) { resolvedMime = 'image/webp'; } else { return normalizeError( `Image format not supported by vision models (header: ${headerMimeType || 'unknown'}). Image URL: ${imageUrl}`, 'get_photo_for_listing', ); } } const base64 = Buffer.from(buffer).toString('base64'); return { content: [ { type: 'image', data: base64, mimeType: resolvedMime, }, ], }; }, ); // ── get_current_date_ime ───────────────────────────────────────────────────── server.tool('get_current_date_time', 'Returns the current date and time.', {}, () => { return { content: [{ type: 'text', text: `Timestring: ${new Date().toLocaleString()}, MS since 1970: ${Date.now()}` }], }; }); return server; }