transparentcars-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "transparentcars",
3
+ "description": "Search budget used cars in Portugal, check whether an asking price is fair, estimate a car's yearly running cost, and reach TransparentCars.",
4
+ "version": "0.1.0",
5
+ "vendor": "TransparentCars",
6
+ "homepage": "https://transparent.pt",
7
+ "documentation": "https://github.com/transparentpt/mcp",
8
+ "license": "MIT",
9
+ "transports": {
10
+ "streamable_http": { "url": "https://transparent.pt/mcp" },
11
+ "stdio": { "command": "npx", "args": ["-y", "transparentcars-mcp"] }
12
+ },
13
+ "tools": [
14
+ "search_inventory",
15
+ "get_car",
16
+ "check_fair_price",
17
+ "cost_to_own",
18
+ "list_makes_models",
19
+ "contact_me"
20
+ ]
21
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TransparentCars
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ <p align="center">
2
+ <img src="./assets/wordmark.png" alt="TransparentCars" width="320" />
3
+ </p>
4
+
5
+ # TransparentCars MCP server
6
+
7
+ [![MCP](https://img.shields.io/badge/MCP-server-blue)](https://modelcontextprotocol.io) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE)
8
+
9
+ An [MCP](https://modelcontextprotocol.io) server that lets AI assistants work with **[TransparentCars](https://transparent.pt)** — honest, budget used cars for the local Portuguese market. Search live stock, check whether a car's asking price is actually fair, estimate the yearly cost of keeping a car on the road, and get in touch.
10
+
11
+ It's a thin, open-source wrapper over the public `transparent.pt` API — no keys, no setup beyond adding the server.
12
+
13
+ ## Tools
14
+
15
+ | Tool | What it does |
16
+ |------|--------------|
17
+ | `search_inventory` | Search current budget stock by make, model, fuel, gearbox, price, year |
18
+ | `get_car` | Full details + specs for one car |
19
+ | `check_fair_price` | The market range a car sells for — plus a fair / above-market / below-market verdict on an asking price |
20
+ | `cost_to_own` | The annual IUC road tax + a plain running-cost framing for budgeting |
21
+ | `list_makes_models` | Canonical brand/model names the other tools expect |
22
+ | `contact_me` | Ask a human to get in touch (needs the user's consent + contact) |
23
+
24
+ The **fair-price check** is the point: paste any listing's price and get a straight answer on whether it's a good deal — no sales spin.
25
+
26
+ ## Use it
27
+
28
+ ### Remote (add a URL)
29
+
30
+ ```
31
+ https://transparent.pt/mcp
32
+ ```
33
+
34
+ Add it in any client that supports remote/Streamable HTTP MCP servers.
35
+
36
+ ### Local (stdio, via npx)
37
+
38
+ Claude Desktop / Cline / Continue config:
39
+
40
+ ```json
41
+ {
42
+ "mcpServers": {
43
+ "transparentcars": {
44
+ "command": "npx",
45
+ "args": ["-y", "transparentcars-mcp"]
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ ## Examples
52
+
53
+ - "Find a manual petrol car under €10,000 in TransparentCars stock."
54
+ - "A dealer wants €9,500 for a 2014 Renault Clio with 120,000 km — is that fair?"
55
+ - "What's the yearly road tax on a 2016 petrol, 1200 cc, 120 g/km CO₂?"
56
+ - "Ask TransparentCars to call me — my name is … and my number is …"
57
+
58
+ ## Configuration
59
+
60
+ | Env var | Default | Purpose |
61
+ |---------|---------|---------|
62
+ | `TC_API_BASE` | `https://transparent.pt/api/public` | Public API base |
63
+ | `TC_SITE` | `transparentcars` | Site key the API is scoped to |
64
+ | `TC_LANG` | `en` | Response language (pt/en) |
65
+ | `PORT` / `MCP_PATH` | `8099` / `/mcp` | HTTP server (remote mode) |
66
+
67
+ ## Develop
68
+
69
+ ```bash
70
+ npm install
71
+ npm run build
72
+ node dist/index.js # stdio
73
+ npm run start:http # remote HTTP on :8099/mcp
74
+ ```
75
+
76
+ ## Notes
77
+
78
+ - **Estimates, not guarantees.** The fair-price range and running-cost figures are estimates built from real comparable listings and the official road-tax table; treat them as guidance, not a formal valuation.
79
+ - **Privacy.** `contact_me` sends the details the user provides to TransparentCars ([privacy policy](https://transparent.pt/en/privacy)); it requires explicit user intent and consent.
80
+ - No business logic or data lives in this repo — it only calls the public API.
81
+
82
+ MIT © TransparentCars
package/dist/api.js ADDED
@@ -0,0 +1,45 @@
1
+ // Thin client for the public transparent.pt API. No secrets, no business logic here —
2
+ // everything (inventory, valuation, road tax, leads) lives behind these public endpoints.
3
+ export const API_BASE = process.env.TC_API_BASE?.replace(/\/$/, "") ||
4
+ "https://transparent.pt/api/public";
5
+ export const SITE = process.env.TC_SITE || "transparentcars";
6
+ export const DEFAULT_LANG = process.env.TC_LANG || "en";
7
+ const UA = "transparentcars-mcp/0.1";
8
+ async function req(path, init) {
9
+ const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
10
+ const res = await fetch(url, {
11
+ ...init,
12
+ headers: { "user-agent": UA, accept: "application/json", ...(init?.headers || {}) },
13
+ });
14
+ const text = await res.text();
15
+ let data = null;
16
+ try {
17
+ data = text ? JSON.parse(text) : null;
18
+ }
19
+ catch {
20
+ data = { raw: text };
21
+ }
22
+ if (!res.ok) {
23
+ const detail = data?.detail || data?.raw || res.statusText;
24
+ throw new Error(`API ${res.status}: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
25
+ }
26
+ return data;
27
+ }
28
+ export function apiGet(path, params) {
29
+ const qs = new URLSearchParams();
30
+ if (params) {
31
+ for (const [k, v] of Object.entries(params)) {
32
+ if (v !== undefined && v !== null && v !== "")
33
+ qs.set(k, String(v));
34
+ }
35
+ }
36
+ const sep = path.includes("?") ? "&" : "?";
37
+ return req(qs.toString() ? `${path}${sep}${qs}` : path);
38
+ }
39
+ export function apiPost(path, body) {
40
+ return req(path, {
41
+ method: "POST",
42
+ headers: { "content-type": "application/json" },
43
+ body: JSON.stringify(body),
44
+ });
45
+ }
package/dist/http.js ADDED
@@ -0,0 +1,68 @@
1
+ // TransparentCars MCP server — remote Streamable HTTP transport (for "add a URL" clients).
2
+ // Stateless: a fresh server+transport per request (simple, horizontally scalable).
3
+ import { createServer } from "node:http";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
+ import { registerTools } from "./tools.js";
7
+ const PORT = Number(process.env.PORT || 8099);
8
+ const HOST = process.env.MCP_HOST || "127.0.0.1"; // bind loopback; front with an (auth) proxy, never all-interfaces
9
+ const MCP_PATH = process.env.MCP_PATH || "/mcp";
10
+ // Anti-DNS-rebinding: this SDK's StreamableHTTPServerTransport doesn't expose allowedHosts, so gate here.
11
+ const ALLOWED_HOSTS = (process.env.MCP_ALLOWED_HOSTS || "transparent.pt,mcp.transparent.pt,localhost:8099,127.0.0.1:8099")
12
+ .split(",").map((s) => s.trim().toLowerCase());
13
+ const ALLOWED_ORIGINS = (process.env.MCP_ALLOWED_ORIGINS || "https://transparent.pt")
14
+ .split(",").map((s) => s.trim());
15
+ function originAllowed(req) {
16
+ const host = String(req.headers.host || "").toLowerCase();
17
+ if (!ALLOWED_HOSTS.includes(host))
18
+ return false; // blocks rebinding to attacker Host
19
+ const origin = req.headers.origin; // native MCP clients send none → allowed
20
+ if (origin && !ALLOWED_ORIGINS.includes(origin))
21
+ return false; // blocks browser cross-origin
22
+ return true;
23
+ }
24
+ function readBody(req) {
25
+ return new Promise((resolve) => {
26
+ let raw = "";
27
+ req.on("data", (c) => (raw += c));
28
+ req.on("end", () => {
29
+ try {
30
+ resolve(raw ? JSON.parse(raw) : undefined);
31
+ }
32
+ catch {
33
+ resolve(undefined);
34
+ }
35
+ });
36
+ });
37
+ }
38
+ async function handleMcp(req, res) {
39
+ const server = new McpServer({ name: "transparentcars", version: "0.1.0" });
40
+ registerTools(server);
41
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
42
+ res.on("close", () => {
43
+ transport.close();
44
+ server.close();
45
+ });
46
+ await server.connect(transport);
47
+ await transport.handleRequest(req, res, await readBody(req));
48
+ }
49
+ createServer((req, res) => {
50
+ const url = new URL(req.url || "/", `http://${req.headers.host}`);
51
+ if (url.pathname === "/healthz") {
52
+ res.writeHead(200, { "content-type": "text/plain" }).end("ok");
53
+ return;
54
+ }
55
+ if (url.pathname === MCP_PATH) {
56
+ if (!originAllowed(req)) {
57
+ res.writeHead(403, { "content-type": "text/plain" }).end("forbidden");
58
+ return;
59
+ }
60
+ handleMcp(req, res).catch((e) => {
61
+ console.error("mcp error", e);
62
+ if (!res.headersSent)
63
+ res.writeHead(500).end();
64
+ });
65
+ return;
66
+ }
67
+ res.writeHead(404).end();
68
+ }).listen(PORT, HOST, () => console.error(`transparentcars-mcp HTTP on ${HOST}:${PORT}${MCP_PATH}`));
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // TransparentCars MCP server — stdio transport (for `npx transparentcars-mcp`, Claude Desktop, Cline, etc.)
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { registerTools } from "./tools.js";
6
+ const server = new McpServer({ name: "transparentcars", version: "0.1.0" });
7
+ registerTools(server);
8
+ const transport = new StdioServerTransport();
9
+ await server.connect(transport);
10
+ // stderr only — stdout is the MCP channel
11
+ console.error("transparentcars-mcp running on stdio");
package/dist/tools.js ADDED
@@ -0,0 +1,184 @@
1
+ import { z } from "zod";
2
+ import { apiGet, apiPost, SITE, DEFAULT_LANG } from "./api.js";
3
+ const SITE_URL = "https://transparent.pt";
4
+ const text = (s) => ({ content: [{ type: "text", text: s }] });
5
+ const eur = (n) => (typeof n === "number" ? `€${Math.round(n).toLocaleString("en-US")}` : "—");
6
+ const carUrl = (slug) => `${SITE_URL}/${DEFAULT_LANG}/car/${slug}`;
7
+ export function registerTools(server) {
8
+ // ---- search_inventory ---------------------------------------------------
9
+ server.tool("search_inventory", "Search TransparentCars' current stock of budget used cars in Portugal — honestly listed, everyday cars for the local market. Filter by make, model, fuel, gearbox and max price. Returns matching cars with price, year, mileage and a link. IMPORTANT — pass make/model canonically in Latin, as the Portuguese market lists them: make = full brand name ('BMW', 'Mercedes-Benz' not 'Mercedes', 'Volkswagen' not 'VW'); model = the short series/model WITHOUT the engine or trim suffix ('Golf' not 'Golf 1.5 TSI', 'Clio' not 'Clio dCi'). Put the fuel in the fuel field, not the model. If unsure of the spelling, call list_makes_models first.", {
10
+ make: z.string().optional().describe("Full brand name in Latin, as the market lists it — 'Renault', 'Volkswagen' (not 'VW'), 'Peugeot', 'Seat'."),
11
+ model: z.string().optional().describe("Short model name WITHOUT engine or trim — 'Clio' (not 'Clio 1.5 dCi'), 'Golf' (not 'Golf TSI')."),
12
+ fuel: z.string().optional().describe("Fuel: petrol, diesel, hybrid, phev, electric, lpg"),
13
+ gearbox: z.string().optional().describe("Gearbox: manual or automatic"),
14
+ max_price: z.number().optional().describe("Maximum price in EUR"),
15
+ min_year: z.number().optional().describe("Earliest registration year"),
16
+ limit: z.number().int().min(1).max(50).optional().describe("Max results (default 20)"),
17
+ }, async (a) => {
18
+ const params = { site: SITE, lang: DEFAULT_LANG };
19
+ if (a.make)
20
+ params.make = a.make;
21
+ if (a.fuel)
22
+ params.fuel = a.fuel;
23
+ if (a.gearbox)
24
+ params.gearbox = a.gearbox;
25
+ if (typeof a.max_price === "number")
26
+ params.max_price = a.max_price;
27
+ const data = await apiGet("/cars", params);
28
+ let cars = data?.cars || [];
29
+ const norm = (s) => String(s || "").toLowerCase();
30
+ // Client-side narrowing for filters the API doesn't take (model, min_year) and as a safety net.
31
+ if (a.make)
32
+ cars = cars.filter((c) => norm(c.make).includes(norm(a.make)));
33
+ if (a.model)
34
+ cars = cars.filter((c) => norm(c.model).includes(norm(a.model)));
35
+ if (a.fuel)
36
+ cars = cars.filter((c) => norm(c.fuel).includes(norm(a.fuel)) || norm(c.fuel_code).includes(norm(a.fuel)));
37
+ if (a.gearbox)
38
+ cars = cars.filter((c) => norm(c.gearbox).includes(norm(a.gearbox)));
39
+ if (typeof a.max_price === "number")
40
+ cars = cars.filter((c) => (c.price_eur ?? Infinity) <= a.max_price);
41
+ if (typeof a.min_year === "number")
42
+ cars = cars.filter((c) => (c.year ?? 0) >= a.min_year);
43
+ cars = cars.slice(0, a.limit ?? 20);
44
+ if (!cars.length)
45
+ return text("No cars in stock match those filters right now. Try widening the search, or browse everything at " + `${SITE_URL}/${DEFAULT_LANG}/cars`);
46
+ const lines = cars.map((c) => `• ${c.make} ${c.model} (${c.year || "—"}) — ${eur(c.price_eur)}, ${(c.km ?? 0).toLocaleString("en-US")} km, ${c.fuel || "—"}${c.gearbox ? ", " + c.gearbox : ""}\n ${carUrl(c.slug)}`);
47
+ return text(`${cars.length} car(s) in stock:\n\n${lines.join("\n")}`);
48
+ });
49
+ // ---- get_car ------------------------------------------------------------
50
+ server.tool("get_car", "Get full details for one car in TransparentCars stock by its slug (from a search_inventory link, e.g. 'renault-clio-2016').", { slug: z.string().describe("Car slug from a search_inventory result URL") }, async (a) => {
51
+ const c = await apiGet(`/cars/${encodeURIComponent(a.slug)}`, { site: SITE, lang: DEFAULT_LANG });
52
+ const specs = [
53
+ `Price: ${eur(c.price_eur)}`,
54
+ `Year: ${c.year || "—"}`,
55
+ `Mileage: ${(c.km ?? 0).toLocaleString("en-US")} km`,
56
+ `Fuel: ${c.fuel || "—"}`,
57
+ `Gearbox: ${c.gearbox || "—"}`,
58
+ `Body: ${c.body || "—"}`,
59
+ c.hp ? `Power: ${c.hp} hp` : null,
60
+ c.cc ? `Engine: ${c.cc} cc` : null,
61
+ c.co2 ? `CO₂: ${c.co2} g/km` : null,
62
+ c.colour ? `Colour: ${c.colour}` : null,
63
+ ].filter(Boolean).join("\n");
64
+ const desc = c.description ? `\n\n${c.description}` : "";
65
+ return text(`${c.make} ${c.model} (${c.year || "—"})\n${specs}${desc}\n\n${carUrl(c.slug)}`);
66
+ });
67
+ // ---- check_fair_price ---------------------------------------------------
68
+ server.tool("check_fair_price", "Check whether a car's asking price is fair for the Portuguese market — the TransparentCars transparency check. Given make, model, year and mileage, it returns the range comparable cars actually sell for (low / typical / high), built from real listings. If you pass asking_price, it also gives a plain verdict: fair, above market, or below market — so a buyer knows before they walk in. Works for ANY car (a listing you found elsewhere, or one of ours). IMPORTANT — pass make/model canonically in Latin: make = full brand name ('Renault', 'Volkswagen' not 'VW'); model = short name WITHOUT engine/trim ('Clio' not 'Clio dCi', 'Golf' not 'Golf 1.5 TSI'). If unsure of spelling, call list_makes_models first.", {
69
+ make: z.string().describe("Full brand name in Latin — 'Renault', 'Volkswagen' (not 'VW'), 'Peugeot', 'Seat'."),
70
+ model: z.string().describe("Short model name WITHOUT engine or trim — 'Clio' (not 'Clio 1.5 dCi'), 'Golf' (not 'Golf TSI')."),
71
+ year: z.number().int().describe("Registration year"),
72
+ km: z.number().int().describe("Mileage in km"),
73
+ fuel: z.string().optional().describe("Fuel: petrol, diesel, hybrid, electric"),
74
+ gearbox: z.string().optional().describe("Gearbox: manual or automatic"),
75
+ hp: z.number().int().optional().describe("Power in hp (sharpens the comparison)"),
76
+ asking_price: z.number().optional().describe("The price being asked for THIS car, in EUR — pass it to get a fair / above-market / below-market verdict."),
77
+ }, async (a) => {
78
+ const v = await apiGet("/valuation", {
79
+ make: a.make, model: a.model, year: a.year, km: a.km,
80
+ fuel: a.fuel, gearbox: a.gearbox, hp: a.hp,
81
+ });
82
+ const carLbl = `${a.make} ${a.model} ${a.year}, ${a.km.toLocaleString("en-US")} km`;
83
+ if (!v?.found)
84
+ return text(`Not enough comparable listings to price that ${a.make} ${a.model} automatically right now. ` +
85
+ `Try again without the fuel/gearbox filters, or ask us to look manually via contact_me.`);
86
+ const rough = v.rough ? " (rough — few comparables)" : "";
87
+ const lines = [
88
+ `Fair-price check for ${carLbl}${rough}:`,
89
+ `• Typical (market): ${eur(v.median)}`,
90
+ `• Fair range: ${eur(v.low)} – ${eur(v.high)}`,
91
+ v.days_sell ? `• Avg. time to sell: ~${v.days_sell} days` : null,
92
+ `Based on ${v.comps} comparable listing(s) — the range similar cars actually go for.`,
93
+ ].filter(Boolean);
94
+ if (typeof a.asking_price === "number") {
95
+ const ask = a.asking_price;
96
+ let verdict;
97
+ if (ask <= v.low) {
98
+ const under = Math.round(((v.median - ask) / v.median) * 100);
99
+ verdict = `Asking ${eur(ask)} → BELOW MARKET. That's under the fair range${under > 0 ? ` (~${under}% below typical)` : ""}. Good on price — check why it's cheap (condition, history, mileage), but the number itself is buyer-friendly.`;
100
+ }
101
+ else if (ask >= v.high) {
102
+ const over = Math.round(((ask - v.median) / v.median) * 100);
103
+ verdict = `Asking ${eur(ask)} → ABOVE MARKET. That's over the fair range${over > 0 ? ` (~${over}% above typical)` : ""}. There's room to negotiate down, or the seller should justify the premium (low km, full history, extras).`;
104
+ }
105
+ else {
106
+ verdict = `Asking ${eur(ask)} → FAIR. It sits inside the range comparable cars sell for. A reasonable ask — small negotiation room as always.`;
107
+ }
108
+ lines.push("", verdict);
109
+ }
110
+ else {
111
+ lines.push("", "Pass asking_price to get a fair / above-market / below-market verdict on a specific listing.");
112
+ }
113
+ return text(lines.join("\n"));
114
+ });
115
+ // ---- cost_to_own --------------------------------------------------------
116
+ server.tool("cost_to_own", "Estimate a car's yearly running-cost floor in Portugal — the annual IUC road tax (Imposto Único de Circulação) every owner pays, plus a plain running-cost framing. Needs engine displacement (cc), CO₂ (g/km), fuel and first-registration year. Use this to help a buyer budget the true cost of keeping a car, beyond the sticker price.", {
117
+ cc: z.number().int().describe("Engine displacement in cm³, e.g. 1200"),
118
+ co2: z.number().describe("CO₂ emissions in g/km, e.g. 120"),
119
+ fuel: z.string().describe("Fuel: petrol, diesel, hybrid, phev, electric, lpg"),
120
+ year: z.number().int().describe("First registration year, e.g. 2016"),
121
+ month: z.number().int().min(1).max(12).optional().describe("First-registration month (1-12), refines the CO₂ regime"),
122
+ used: z.boolean().optional().describe("Used vehicle (true, default)"),
123
+ }, async (a) => {
124
+ const r = await apiGet("/isv", {
125
+ cc: a.cc, co2: a.co2, fuel: a.fuel, year: a.year,
126
+ month: a.month, used: a.used === false ? 0 : 1,
127
+ });
128
+ const iuc = r?.iuc;
129
+ if (!iuc || typeof iuc.iuc !== "number")
130
+ return text(`Couldn't work out the road tax for those inputs. Double-check cc, CO₂ and year.`);
131
+ if (iuc.exempt)
132
+ return text(`Annual road tax (IUC): €0/year — exempt.${iuc.note ? " " + iuc.note : ""}\n\nSo the yearly tax floor on this car is nothing; you'd still budget insurance, inspection (IPO) and servicing.`);
133
+ const annual = iuc.iuc;
134
+ const parts = [
135
+ `Annual road tax (IUC): ${eur(annual)}/year`,
136
+ typeof iuc.taxa_cilindrada === "number" ? ` engine component: ${eur(iuc.taxa_cilindrada)}` : null,
137
+ typeof iuc.taxa_co2 === "number" && iuc.taxa_co2 ? ` CO₂ component: ${eur(iuc.taxa_co2)}` : null,
138
+ typeof iuc.diesel_adic === "number" && iuc.diesel_adic ? ` diesel component: ${eur(iuc.diesel_adic)}` : null,
139
+ ].filter(Boolean);
140
+ const framing = `\n\nThat IUC is the guaranteed yearly cost just to keep it road-legal (~${eur(Math.round(annual / 12))}/month). ` +
141
+ `On top of it, budget insurance, the annual IPO inspection and routine servicing — the real cost of owning the car, not just buying it.` +
142
+ `${iuc.note ? "\n" + iuc.note : ""}\n\nEstimate from the official IUC table; confirm the exact figure with Finanças.`;
143
+ return text(parts.join("\n") + framing);
144
+ });
145
+ // ---- list_makes_models -------------------------------------------------
146
+ server.tool("list_makes_models", "List the exact canonical car makes (brands) TransparentCars can search and price — or, given a make, that brand's models — using the exact strings the other tools expect. Call this FIRST (or whenever unsure of spelling) and pass the returned values verbatim into search_inventory / check_fair_price. No make = the list of brands; with a make = that brand's models.", { make: z.string().optional().describe("Brand to list models for, e.g. 'Renault'. Omit to get the list of brands.") }, async (a) => {
147
+ const r = await apiGet("/car_refs", a.make ? { make: a.make } : {});
148
+ if (!a.make) {
149
+ const makes = r?.makes || [];
150
+ return text(makes.length ? `Available makes (pass one of these EXACTLY as 'make'):\n${makes.join(", ")}` : "Couldn't load the make list right now.");
151
+ }
152
+ const models = r?.models || [];
153
+ return text(models.length
154
+ ? `Models for ${r.make} (pass one of these EXACTLY as 'model'):\n${models.join(", ")}`
155
+ : `No models found for '${a.make}'. Check the make spelling by calling list_makes_models with no argument.`);
156
+ });
157
+ // ---- contact_me ---------------------------------------------------------
158
+ server.tool("contact_me", "Send a contact request to TransparentCars so a human gets in touch. Only call this when the user explicitly asks to be contacted, provides their own name and a phone or email, AND agrees to share those details — pass consent=true only then. Submitting shares the provided contact details per the TransparentCars privacy policy.", {
159
+ name: z.string().describe("The person's name (as they gave it)"),
160
+ contact: z.string().describe("A phone number or email where they can be reached"),
161
+ message: z.string().optional().describe("What they want / their question"),
162
+ consent: z.boolean().describe("Set true ONLY if the user explicitly agreed to share their contact details and be contacted"),
163
+ }, async (a) => {
164
+ if (!a.consent)
165
+ return text("Before sending, the user must explicitly agree to share their contact details with TransparentCars. Confirm consent, then call again with consent=true.");
166
+ if (!a.contact || !a.contact.trim())
167
+ return text("A phone number or email is required to send a contact request. Ask the user for one.");
168
+ const val = a.contact.trim();
169
+ const isEmail = val.includes("@");
170
+ const r = await apiPost("/lead", {
171
+ kind: "contact",
172
+ name: a.name,
173
+ email: isEmail ? val : undefined,
174
+ phone: isEmail ? undefined : val,
175
+ message: a.message,
176
+ lang: DEFAULT_LANG,
177
+ source: "mcp",
178
+ consent: true,
179
+ });
180
+ if (r?.duplicate)
181
+ return text("You already sent this request — TransparentCars has it and will be in touch.");
182
+ return text("Sent ✓ TransparentCars received your request and will contact you soon.");
183
+ });
184
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "transparentcars-mcp",
3
+ "version": "0.1.0",
4
+ "mcpName": "io.github.transparentpt/mcp",
5
+ "description": "MCP server for TransparentCars — search budget used cars in Portugal, check whether an asking price is fair, estimate a car's yearly running cost, and get in touch. Thin wrapper over the public transparent.pt API.",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "bin": {
9
+ "transparentcars-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ ".well-known"
14
+ ],
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "cars",
19
+ "portugal",
20
+ "used-cars",
21
+ "budget-cars",
22
+ "automotive"
23
+ ],
24
+ "homepage": "https://transparent.pt",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/transparentpt/mcp"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "dev": "tsc --watch",
32
+ "start": "node dist/index.js",
33
+ "start:http": "node dist/http.js",
34
+ "prepublishOnly": "npm run build"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.0.4",
41
+ "zod": "^3.23.8"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22.0.0",
45
+ "typescript": "^5.5.0"
46
+ }
47
+ }