export_md
Save the last property search results directly to a Markdown file in an approved directory, enabling report generation and offline analysis of Singapore HDB transactions and amenities.
Instructions
Export the last search results to a Markdown file. The file must be saved within a directory approved by the client.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| filePath | Yes | Full path for the Markdown file (e.g. '/home/user/exports/results.md') |
Implementation Reference
- src/tools/export.ts:94-180 (handler)The export_md tool handler — registered via server.tool('export_md', ...). It retrieves the last search results, formats them as a Markdown table (using formatters), builds a full Markdown document with query params and attribution, validates the output path against client-approved roots, and writes the file.
server.tool( "export_md", "Export the last search results to a Markdown file. The file must be saved within a directory approved by the client.", { filePath: z .string() .describe("Full path for the Markdown file (e.g. '/home/user/exports/results.md')"), }, async ({ filePath }, extra: ToolExtra) => { await logInfo(extra, `export_md: target="${filePath}"`); const lastSearch = state.getLastSearch(); if (!lastSearch) { return { content: [{ type: "text" as const, text: "No search results to export. Run a search first." }] }; } if (lastSearch.results.length === 0) { return { content: [{ type: "text" as const, text: "The last search returned no results \u2014 nothing to export." }] }; } const check = await isPathAllowed(server.server, filePath); if (!check.allowed) { await logInfo(extra, `export_md: path denied \u2014 ${check.reason}`); const rootsList = check.roots.length > 0 ? `\n\nAllowed directories:\n${check.roots.map((r) => `- ${r}`).join("\n")}` : ""; return { content: [{ type: "text" as const, text: `Cannot write to "${filePath}". ${check.reason}${rootsList}` }] }; } await logInfo(extra, `export_md: path allowed (root: ${check.root})`); let table: string; switch (lastSearch.type) { case "land-use": table = formatLandParcelsTable(lastSearch.results as LandParcel[]); break; case "nearby-amenities": table = formatNearbyAmenityTable(lastSearch.results as NearbyAmenity[]); break; default: table = formatHdbTable(lastSearch.results as HdbResaleRecord[]); break; } const queryLines = Object.entries(lastSearch.query) .filter(([, v]) => v != null) .map(([k, v]) => `- **${k}:** ${v}`); const heading = HEADING_MAP[lastSearch.type] ?? "Results"; const attribution = ATTRIBUTION_MAP[lastSearch.type] ?? "(c) Urban Redevelopment Authority"; const exportedAt = new Date().toLocaleString(undefined, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", timeZoneName: "short", }); const md = [ `# ${heading}`, "", `**Exported:** ${exportedAt}`, "", "## Query Parameters", "", ...queryLines, "", "## Results", "", table, "", "---", attribution, "", ].join("\n"); try { await mkdir(path.dirname(path.resolve(filePath)), { recursive: true }); await writeFile(path.resolve(filePath), md, "utf-8"); } catch (error) { const message = error instanceof Error ? error.message : String(error); await logInfo(extra, `export_md: write failed \u2014 ${message}`); return { content: [{ type: "text" as const, text: `Failed to write file: ${message}` }] }; } const resolved = path.resolve(filePath); const rowCount = lastSearch.results.length; const dataType = DATA_TYPE_LABELS[lastSearch.type] ?? lastSearch.type; await logInfo(extra, `export_md: wrote ${rowCount} ${dataType} records to ${resolved}`); return { content: [{ type: "text" as const, text: `Exported ${rowCount} ${dataType} records to:\n${resolved}` }] }; }, ); - src/tools/export.ts:97-101 (schema)Input schema for export_md: a single required string parameter 'filePath' described as the full path for the Markdown file.
{ filePath: z .string() .describe("Full path for the Markdown file (e.g. '/home/user/exports/results.md')"), }, - src/tools/export.ts:35-181 (registration)Registration function registerExportTools which calls server.tool('export_md', ...) and server.tool('export_csv', ...). Exported and called from src/index.ts line 20.
export function registerExportTools(server: McpServer, state: SessionState): void { server.tool( "export_csv", "Export the last search results to a CSV file. The file must be saved within a directory approved by the client.", { filePath: z .string() .describe("Full path for the CSV file (e.g. '/home/user/exports/results.csv')"), }, async ({ filePath }, extra: ToolExtra) => { await logInfo(extra, `export_csv: target="${filePath}"`); const lastSearch = state.getLastSearch(); if (!lastSearch) { return { content: [{ type: "text" as const, text: "No search results to export. Run a search first." }] }; } if (lastSearch.results.length === 0) { return { content: [{ type: "text" as const, text: "The last search returned no results \u2014 nothing to export." }] }; } const check = await isPathAllowed(server.server, filePath); if (!check.allowed) { await logInfo(extra, `export_csv: path denied \u2014 ${check.reason}`); const rootsList = check.roots.length > 0 ? `\n\nAllowed directories:\n${check.roots.map((r) => `- ${r}`).join("\n")}` : ""; return { content: [{ type: "text" as const, text: `Cannot write to "${filePath}". ${check.reason}${rootsList}` }] }; } await logInfo(extra, `export_csv: path allowed (root: ${check.root})`); let csv: string; switch (lastSearch.type) { case "land-use": csv = formatLandParcelsCsv(lastSearch.results as LandParcel[]); break; case "nearby-amenities": csv = formatNearbyAmenityCsv(lastSearch.results as NearbyAmenity[]); break; default: csv = formatHdbCsv(lastSearch.results as HdbResaleRecord[]); break; } try { await mkdir(path.dirname(path.resolve(filePath)), { recursive: true }); await writeFile(path.resolve(filePath), csv, "utf-8"); } catch (error) { const message = error instanceof Error ? error.message : String(error); await logInfo(extra, `export_csv: write failed \u2014 ${message}`); return { content: [{ type: "text" as const, text: `Failed to write file: ${message}` }] }; } const resolved = path.resolve(filePath); const rowCount = lastSearch.results.length; const dataType = DATA_TYPE_LABELS[lastSearch.type] ?? lastSearch.type; await logInfo(extra, `export_csv: wrote ${rowCount} ${dataType} records to ${resolved}`); return { content: [{ type: "text" as const, text: `Exported ${rowCount} ${dataType} records to:\n${resolved}` }] }; }, ); server.tool( "export_md", "Export the last search results to a Markdown file. The file must be saved within a directory approved by the client.", { filePath: z .string() .describe("Full path for the Markdown file (e.g. '/home/user/exports/results.md')"), }, async ({ filePath }, extra: ToolExtra) => { await logInfo(extra, `export_md: target="${filePath}"`); const lastSearch = state.getLastSearch(); if (!lastSearch) { return { content: [{ type: "text" as const, text: "No search results to export. Run a search first." }] }; } if (lastSearch.results.length === 0) { return { content: [{ type: "text" as const, text: "The last search returned no results \u2014 nothing to export." }] }; } const check = await isPathAllowed(server.server, filePath); if (!check.allowed) { await logInfo(extra, `export_md: path denied \u2014 ${check.reason}`); const rootsList = check.roots.length > 0 ? `\n\nAllowed directories:\n${check.roots.map((r) => `- ${r}`).join("\n")}` : ""; return { content: [{ type: "text" as const, text: `Cannot write to "${filePath}". ${check.reason}${rootsList}` }] }; } await logInfo(extra, `export_md: path allowed (root: ${check.root})`); let table: string; switch (lastSearch.type) { case "land-use": table = formatLandParcelsTable(lastSearch.results as LandParcel[]); break; case "nearby-amenities": table = formatNearbyAmenityTable(lastSearch.results as NearbyAmenity[]); break; default: table = formatHdbTable(lastSearch.results as HdbResaleRecord[]); break; } const queryLines = Object.entries(lastSearch.query) .filter(([, v]) => v != null) .map(([k, v]) => `- **${k}:** ${v}`); const heading = HEADING_MAP[lastSearch.type] ?? "Results"; const attribution = ATTRIBUTION_MAP[lastSearch.type] ?? "(c) Urban Redevelopment Authority"; const exportedAt = new Date().toLocaleString(undefined, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", timeZoneName: "short", }); const md = [ `# ${heading}`, "", `**Exported:** ${exportedAt}`, "", "## Query Parameters", "", ...queryLines, "", "## Results", "", table, "", "---", attribution, "", ].join("\n"); try { await mkdir(path.dirname(path.resolve(filePath)), { recursive: true }); await writeFile(path.resolve(filePath), md, "utf-8"); } catch (error) { const message = error instanceof Error ? error.message : String(error); await logInfo(extra, `export_md: write failed \u2014 ${message}`); return { content: [{ type: "text" as const, text: `Failed to write file: ${message}` }] }; } const resolved = path.resolve(filePath); const rowCount = lastSearch.results.length; const dataType = DATA_TYPE_LABELS[lastSearch.type] ?? lastSearch.type; await logInfo(extra, `export_md: wrote ${rowCount} ${dataType} records to ${resolved}`); return { content: [{ type: "text" as const, text: `Exported ${rowCount} ${dataType} records to:\n${resolved}` }] }; }, ); } - src/formatters.ts:58-70 (helper)formatNearbyAmenityTable helper used by export_md for amenities data.
export function formatNearbyAmenityTable(records: NearbyAmenity[]): string { if (records.length === 0) { return "No nearby amenities found matching your criteria."; } const header = "| Category | Name | Distance | Address |"; const divider = "|---|---|---|---|"; const rows = records.map((r) => `| ${CATEGORY_LABELS[r.category] ?? r.category} | ${r.name} | ${formatDistance(r.distanceMeters)} | ${r.address ?? "—"} |`, ); return [header, divider, ...rows].join("\n"); } - src/formatters.ts:7-20 (helper)formatLandParcelsTable helper used by export_md for land-use data.
export function formatLandParcelsTable(parcels: LandParcel[]): string { if (parcels.length === 0) { return "No land parcels found in this area."; } const header = "| Land Use | Plot Ratio | Region | Planning Area | Subzone |"; const divider = "|---|---|---|---|---|"; const rows = parcels.map( (p) => `| ${p.landUse} | ${p.grossPlotRatio ?? "N/A"} | ${p.region} | ${p.planningArea} | ${p.subzone} |` ); return [header, divider, ...rows].join("\n"); }