tutti-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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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,251 @@
1
+ # tutti-mcp
2
+
3
+ > Search tutti.ch Swiss classifieds from an AI agent or a JSON CLI — anonymous and read-only.
4
+
5
+ [![MCP](https://img.shields.io/badge/MCP-server-blue)](https://modelcontextprotocol.io)
6
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
7
+
8
+ `tutti-mcp` exposes the same compact tutti.ch search core through two entry points:
9
+
10
+ - `tutti-mcp`: MCP stdio server for Claude, Codex, Cursor, and other MCP-capable clients
11
+ - `tutti`: CLI with matching subcommands and JSON output
12
+
13
+ Ask your agent things like:
14
+
15
+ - "Search tutti for a bike under 300 CHF in Zürich."
16
+ - "Find free sofas near Bern."
17
+ - "Get details for tutti listing 81828298."
18
+ - "Show me the available tutti categories."
19
+
20
+ The package is intentionally anonymous read-only: no login, messaging, favorites, posting, caching, or bulk export.
21
+
22
+ ---
23
+
24
+ ## Quick Start
25
+
26
+ After publishing to npm, run the MCP server directly with `npx`:
27
+
28
+ ```sh
29
+ npx -y tutti-mcp
30
+ ```
31
+
32
+ Use the CLI through the `tutti` bin:
33
+
34
+ ```sh
35
+ npx -y -p tutti-mcp tutti search velo --max 300 --limit 5
36
+ ```
37
+
38
+ Run from a local checkout:
39
+
40
+ ```sh
41
+ npm install
42
+ npm run build
43
+ node dist/cli.js search velo --max 300 --limit 5
44
+ ```
45
+
46
+ The MCP server speaks stdio, so you normally let your client spawn it.
47
+
48
+ <details>
49
+ <summary><strong>Claude Code</strong></summary>
50
+
51
+ ```sh
52
+ claude mcp add tutti -- npx -y tutti-mcp
53
+ ```
54
+
55
+ Restart Claude Code or start a new session after adding the server, then check `/mcp`.
56
+ </details>
57
+
58
+ <details>
59
+ <summary><strong>Claude Desktop</strong></summary>
60
+
61
+ Add this to `claude_desktop_config.json`:
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "tutti": {
67
+ "command": "npx",
68
+ "args": ["-y", "tutti-mcp"]
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ Restart Claude Desktop after editing the file.
75
+ </details>
76
+
77
+ <details>
78
+ <summary><strong>Codex</strong></summary>
79
+
80
+ ```sh
81
+ codex mcp add tutti -- npx -y tutti-mcp
82
+ ```
83
+
84
+ Start a new Codex session after adding the server; MCP tools are injected when the session starts.
85
+ </details>
86
+
87
+ <details>
88
+ <summary><strong>Cursor / other JSON-config clients</strong></summary>
89
+
90
+ Most clients accept a config of this shape:
91
+
92
+ ```json
93
+ {
94
+ "mcpServers": {
95
+ "tutti": {
96
+ "command": "npx",
97
+ "args": ["-y", "tutti-mcp"]
98
+ }
99
+ }
100
+ }
101
+ ```
102
+ </details>
103
+
104
+ Requires Node.js 20 or newer.
105
+
106
+ ---
107
+
108
+ ## Tools
109
+
110
+ All listing URLs use `https://www.tutti.ch/de/vi/<listingID>`. Prices are returned as tutti.ch formats in CHF, for example `"40.-"`.
111
+
112
+ | Tool | Purpose | Auth required | Writes? |
113
+ | --- | --- | :---: | :---: |
114
+ | `search_listings` | Search listings by query, category, price, locality, radius, sort, and cursor. | No | No |
115
+ | `get_listing` | Fetch compact detail for one listing id, including description, seller, and up to five image URLs. | No | No |
116
+ | `get_categories` | Return `{ id, label, children }` category nodes; ids are usable as `categoryId`. | No | No |
117
+ | `search_localities` | Search Swiss locality names before using location filters. | No | No |
118
+
119
+ `search_listings` returns `nextCursor` when another page is available. Pass that value back as `cursor`.
120
+
121
+ When `location` is provided, the server resolves the text to the top matching Swiss locality. Use `search_localities` first when the place name is ambiguous or misspelled.
122
+
123
+ ---
124
+
125
+ ## Suggested Workflows
126
+
127
+ **Find listings near a place**
128
+
129
+ 1. `search_localities` for the place name if ambiguity matters.
130
+ 2. `search_listings` with `location`, optional `radiusKm`, and price filters.
131
+ 3. Follow `nextCursor` with another `search_listings` call for page 2.
132
+ 4. Use `get_listing` for the items that need full details.
133
+
134
+ **Browse by category**
135
+
136
+ 1. `get_categories`.
137
+ 2. Pick a category `id`.
138
+ 3. `search_listings` with `categoryId`.
139
+
140
+ **Use from the CLI**
141
+
142
+ ```sh
143
+ node dist/cli.js search velo --max 300 --limit 5
144
+ node dist/cli.js search velo --location "Zürich" --radius 10 --max 300
145
+ node dist/cli.js get 81828298
146
+ node dist/cli.js categories
147
+ node dist/cli.js localities "zür"
148
+ ```
149
+
150
+ Installed package bins:
151
+
152
+ ```sh
153
+ npx -y -p tutti-mcp tutti search velo --max 300 --limit 5
154
+ npx -y -p tutti-mcp tutti get 81828298
155
+
156
+ tutti search velo --max 300 --limit 5
157
+ tutti get 81828298
158
+ tutti categories
159
+ tutti localities "zür"
160
+ ```
161
+
162
+ All CLI commands print JSON to stdout. Output is pretty-printed on a TTY and compact otherwise. Errors are written to stderr and return exit code 1.
163
+
164
+ ---
165
+
166
+ ## Safety Model
167
+
168
+ This server performs anonymous read-only operations only.
169
+
170
+ - No credentials are accepted or needed.
171
+ - No listings are posted, edited, favorited, or messaged.
172
+ - No HTTP server is exposed; MCP uses stdio only.
173
+ - No raw `tutti-api` objects are returned to agents. Responses are mapped to compact project-owned payloads.
174
+ - Upstream requests are throttled to avoid rapid repeated traffic and retried only for transient failures.
175
+
176
+ ---
177
+
178
+ ## Develop From Source
179
+
180
+ ```sh
181
+ cd tutti-mcp
182
+ npm install
183
+ npm run build
184
+ ```
185
+
186
+ Point a client at the built entrypoint:
187
+
188
+ ```sh
189
+ claude mcp add tutti -- node /absolute/path/to/tutti-mcp/dist/mcp.js
190
+ ```
191
+
192
+ Before publishing:
193
+
194
+ ```sh
195
+ npm pack --dry-run
196
+ npm publish
197
+ ```
198
+
199
+ ### Verification
200
+
201
+ ```sh
202
+ npm run typecheck
203
+ npm test
204
+ npm run build
205
+ ```
206
+
207
+ Live smoke tests are skipped by default:
208
+
209
+ ```sh
210
+ TUTTI_LIVE=1 npm test
211
+ ```
212
+
213
+ Manual MCP checks:
214
+
215
+ ```sh
216
+ npx @modelcontextprotocol/inspector --cli --method tools/list node dist/mcp.js
217
+ npx @modelcontextprotocol/inspector --cli node dist/mcp.js --method tools/call --tool-name search_listings --tool-arg query=velo --tool-arg priceMax=300
218
+ ```
219
+
220
+ Manual CLI check:
221
+
222
+ ```sh
223
+ node dist/cli.js search velo --max 300 --limit 5
224
+ ```
225
+
226
+ ---
227
+
228
+ ## Troubleshooting
229
+
230
+ | Symptom | Fix |
231
+ | --- | --- |
232
+ | MCP client does not show the tools | Restart the client or start a new session so it respawns the server. |
233
+ | `location` matches the wrong place | Run `search_localities` first, then use a more specific place name. |
234
+ | Search fails with a rate-limit message | Wait a few minutes before retrying; the upstream API is behind Cloudflare. |
235
+ | CLI output is one line | Expected when stdout is not a TTY; pipe it through `jq` if needed. |
236
+ | Live tests are skipped | Set `TUTTI_LIVE=1` explicitly. |
237
+ | Inspector command opens a UI instead of running once | Add `--cli` and an explicit `--method`, as shown above. |
238
+
239
+ ---
240
+
241
+ ## Legal Caveat
242
+
243
+ This package uses a reverse-engineered private API via the `tutti-api` npm package. tutti.ch has no official public API, and its Nutzungsbedingungen prohibit automated queries and reproduction of listings. This project is intended for personal and prototype use only. It does not provide bulk-export or scraping features. Use it responsibly and respect tutti.ch's terms and rate limits.
244
+
245
+ ## Disclaimer
246
+
247
+ This is an unofficial community project and is not affiliated with tutti.ch or SMG Swiss Marketplace Group.
248
+
249
+ ## License
250
+
251
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { T as TuttiAdapter } from './types-CqLkHDpv.js';
2
+
3
+ interface CliIO {
4
+ stdout: {
5
+ write(chunk: string): void;
6
+ isTTY?: boolean;
7
+ };
8
+ stderr: {
9
+ write(chunk: string): void;
10
+ };
11
+ }
12
+ declare function runCli(argv?: string[], adapter?: TuttiAdapter, io?: CliIO, version?: string): Promise<number>;
13
+
14
+ export { runCli };
package/dist/cli.js ADDED
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { readFileSync } from "fs";
5
+ import { parseArgs } from "util";
6
+
7
+ // src/core/client.ts
8
+ import { TuttiClient } from "tutti-api";
9
+
10
+ // src/core/mapping.ts
11
+ var LISTING_URL_BASE = "https://www.tutti.ch/de/vi";
12
+ function buildListingUrl(id) {
13
+ return `${LISTING_URL_BASE}/${encodeURIComponent(id)}`;
14
+ }
15
+ function formatLocation(listing) {
16
+ const info = listing.postcodeInformation;
17
+ const place = [info?.postcode, info?.locationName].filter(Boolean).join(" ").trim();
18
+ const canton = info?.canton?.shortName;
19
+ if (place && canton) {
20
+ return `${place} (${canton})`;
21
+ }
22
+ return place || listing.address || null;
23
+ }
24
+ function mapListingSummary(listing) {
25
+ return {
26
+ id: listing.listingID,
27
+ title: listing.title,
28
+ price: listing.formattedPrice ?? null,
29
+ location: formatLocation(listing),
30
+ category: listing.primaryCategory?.label ?? null,
31
+ timestamp: listing.timestamp ?? null,
32
+ url: buildListingUrl(listing.listingID)
33
+ };
34
+ }
35
+ function mapListingDetail(listing) {
36
+ return {
37
+ ...mapListingSummary(listing),
38
+ description: stringField(listing.body),
39
+ seller: listing.sellerInfo?.publicAccountID ? {
40
+ id: listing.sellerInfo.publicAccountID,
41
+ alias: listing.sellerInfo.alias ?? null
42
+ } : null,
43
+ images: imageUrls(listing),
44
+ address: listing.address ?? null,
45
+ source: listing.formattedSource ?? null,
46
+ language: stringField(listing.language)
47
+ };
48
+ }
49
+ function mapLocality(locality) {
50
+ return {
51
+ id: locality.localityID,
52
+ name: locality.name,
53
+ type: locality.localityType ?? null
54
+ };
55
+ }
56
+ function mapCategoryTree(raw) {
57
+ const candidates = collectCategoryArrays(raw);
58
+ const best = candidates.sort((a, b) => b.length - a.length)[0] ?? [];
59
+ return best.map(mapCategoryNode).filter((node) => node !== null);
60
+ }
61
+ function mapCategoryNode(raw) {
62
+ if (!isRecord(raw)) {
63
+ return null;
64
+ }
65
+ const id = stringField(raw.categoryID ?? raw.id);
66
+ const label = stringField(raw.label ?? raw.name);
67
+ if (!id || !label) {
68
+ return null;
69
+ }
70
+ const children = arrayField(raw.children ?? raw.subcategories ?? raw.childCategories).map(mapCategoryNode).filter((node) => node !== null);
71
+ return { id, label, children };
72
+ }
73
+ function collectCategoryArrays(raw) {
74
+ const arrays = [];
75
+ function walk(value) {
76
+ if (Array.isArray(value)) {
77
+ if (value.some(looksLikeCategory)) {
78
+ arrays.push(value);
79
+ }
80
+ for (const item of value) {
81
+ walk(item);
82
+ }
83
+ return;
84
+ }
85
+ if (isRecord(value)) {
86
+ for (const child of Object.values(value)) {
87
+ walk(child);
88
+ }
89
+ }
90
+ }
91
+ walk(raw);
92
+ return arrays;
93
+ }
94
+ function looksLikeCategory(value) {
95
+ return isRecord(value) && typeof (value.categoryID ?? value.id) === "string" && typeof (value.label ?? value.name) === "string";
96
+ }
97
+ function imageUrls(listing) {
98
+ const images = arrayField(listing.images).map((image) => isRecord(image) && isRecord(image.rendition) ? stringField(image.rendition.src) : null).filter((src) => src !== null);
99
+ if (images.length > 0) {
100
+ return images.slice(0, 5);
101
+ }
102
+ const thumbnail = listing.thumbnail?.rendition?.src;
103
+ return thumbnail ? [thumbnail] : [];
104
+ }
105
+ function arrayField(value) {
106
+ return Array.isArray(value) ? value : [];
107
+ }
108
+ function stringField(value) {
109
+ return typeof value === "string" && value.length > 0 ? value : null;
110
+ }
111
+ function isRecord(value) {
112
+ return typeof value === "object" && value !== null;
113
+ }
114
+
115
+ // src/core/throttle.ts
116
+ var DEFAULT_MIN_DELAY_MS = 700;
117
+ var DEFAULT_RETRY_DELAYS_MS = [1e3, 3e3];
118
+ var runWithThrottleAndRetry = createThrottledRetry();
119
+ function createThrottledRetry(options = {}) {
120
+ const minDelayMs = options.minDelayMs ?? DEFAULT_MIN_DELAY_MS;
121
+ const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
122
+ const now = options.now ?? Date.now;
123
+ const sleep = options.sleep ?? defaultSleep;
124
+ let previous = Promise.resolve();
125
+ let lastStartedAt = null;
126
+ return async function throttledRetry(operation) {
127
+ const runAfterPrevious = previous.catch(() => void 0);
128
+ let release = () => void 0;
129
+ previous = runAfterPrevious.then(() => new Promise((resolve) => {
130
+ release = resolve;
131
+ }));
132
+ await runAfterPrevious;
133
+ try {
134
+ for (let attempt = 0; ; attempt += 1) {
135
+ if (lastStartedAt !== null) {
136
+ const waitMs = minDelayMs - (now() - lastStartedAt);
137
+ if (waitMs > 0) {
138
+ await sleep(waitMs);
139
+ }
140
+ }
141
+ lastStartedAt = now();
142
+ try {
143
+ return await operation();
144
+ } catch (error) {
145
+ if (!shouldRetry(error) || attempt >= retryDelaysMs.length) {
146
+ throw normalizeError(error);
147
+ }
148
+ await sleep(retryDelaysMs[attempt]);
149
+ }
150
+ }
151
+ } finally {
152
+ release();
153
+ }
154
+ };
155
+ }
156
+ function shouldRetry(error) {
157
+ const status = statusFromError(error);
158
+ if (status === void 0) {
159
+ return isNetworkError(error);
160
+ }
161
+ if (isCloudflareBlock(error)) {
162
+ return false;
163
+ }
164
+ return status === 429 || status >= 500;
165
+ }
166
+ function normalizeError(error) {
167
+ if (isCloudflareBlock(error)) {
168
+ return new Error("tutti.ch is rate-limiting this client; wait a few minutes");
169
+ }
170
+ if (error instanceof Error) {
171
+ return error;
172
+ }
173
+ return new Error(String(error));
174
+ }
175
+ function statusFromError(error) {
176
+ return isRecord2(error) && typeof error.status === "number" ? error.status : void 0;
177
+ }
178
+ function isNetworkError(error) {
179
+ return error instanceof Error && (error.name === "TuttiNetworkError" || error.name === "TypeError");
180
+ }
181
+ function isCloudflareBlock(error) {
182
+ if (!isRecord2(error) || error.status !== 403 || typeof error.body !== "string") {
183
+ return false;
184
+ }
185
+ return /<html|<!doctype html/i.test(error.body);
186
+ }
187
+ function isRecord2(value) {
188
+ return typeof value === "object" && value !== null;
189
+ }
190
+ function defaultSleep(ms) {
191
+ return new Promise((resolve) => {
192
+ setTimeout(resolve, ms);
193
+ });
194
+ }
195
+
196
+ // src/core/client.ts
197
+ var singleton = null;
198
+ function getClient() {
199
+ singleton ??= new TuttiClient();
200
+ return singleton;
201
+ }
202
+ function createTuttiAdapter(client = getClient(), run = runWithThrottleAndRetry) {
203
+ return {
204
+ async search(params) {
205
+ const limit = normalizeLimit(params.limit);
206
+ let resolvedLocation;
207
+ let builder = client.search(params.query);
208
+ if (params.categoryId) {
209
+ builder = builder.category(params.categoryId);
210
+ }
211
+ if (params.freeOnly) {
212
+ builder = builder.freeOnly();
213
+ } else if (params.priceMin !== void 0 || params.priceMax !== void 0) {
214
+ builder = builder.price({ min: params.priceMin, max: params.priceMax });
215
+ }
216
+ if (params.location) {
217
+ const matches = await run(() => client.localities.search(params.location ?? ""));
218
+ resolvedLocation = matches[0];
219
+ if (!resolvedLocation) {
220
+ throw new Error(`No locality matched "${params.location}" - try search_localities`);
221
+ }
222
+ builder = builder.location(resolvedLocation);
223
+ if (params.radiusKm !== void 0) {
224
+ builder = builder.radius(params.radiusKm);
225
+ }
226
+ }
227
+ if (params.sort) {
228
+ builder = builder.sort(params.sort, params.direction ?? "desc");
229
+ } else if (params.direction) {
230
+ builder = builder.sort("timestamp", params.direction);
231
+ }
232
+ if (params.cursor) {
233
+ builder = builder.cursor(params.cursor);
234
+ }
235
+ const page = await run(() => builder.fetch());
236
+ return {
237
+ totalCount: page.totalCount,
238
+ resolvedLocation: resolvedLocation?.name,
239
+ listings: page.listings.slice(0, limit).map(mapListingSummary),
240
+ nextCursor: page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null
241
+ };
242
+ },
243
+ async getListing(id) {
244
+ return mapListingDetail(await run(() => client.listings.get(id)));
245
+ },
246
+ async getCategories() {
247
+ return mapCategoryTree(await run(() => client.categories.tree()));
248
+ },
249
+ async searchLocalities(text) {
250
+ return (await run(() => client.localities.search(text))).map(mapLocality);
251
+ }
252
+ };
253
+ }
254
+ function normalizeLimit(limit) {
255
+ if (limit === void 0) {
256
+ return 15;
257
+ }
258
+ if (!Number.isInteger(limit) || limit < 1 || limit > 30) {
259
+ throw new Error("limit must be an integer between 1 and 30");
260
+ }
261
+ return limit;
262
+ }
263
+
264
+ // src/cli.ts
265
+ var HELP = `Usage:
266
+ tutti search <query> [--category id] [--min n] [--max n] [--free] [--location name] [--radius km] [--sort timestamp] [--asc] [--cursor c] [--limit n]
267
+ tutti get <listingId>
268
+ tutti categories
269
+ tutti localities <query>
270
+ tutti --help
271
+ tutti --version`;
272
+ async function runCli(argv = process.argv.slice(2), adapter = createTuttiAdapter(), io = { stdout: process.stdout, stderr: process.stderr }, version = readVersion()) {
273
+ try {
274
+ const command = argv[0];
275
+ if (!command || command === "--help" || command === "-h") {
276
+ io.stdout.write(`${HELP}
277
+ `);
278
+ return 0;
279
+ }
280
+ if (command === "--version" || command === "-v") {
281
+ io.stdout.write(`${version}
282
+ `);
283
+ return 0;
284
+ }
285
+ switch (command) {
286
+ case "search":
287
+ printJson(io, await adapter.search(parseSearchArgs(argv.slice(1))));
288
+ return 0;
289
+ case "get":
290
+ printJson(io, await adapter.getListing(requiredPositional(argv, 1, "listingId")));
291
+ return 0;
292
+ case "categories":
293
+ ensureNoExtraPositionals(argv, 1);
294
+ printJson(io, await adapter.getCategories());
295
+ return 0;
296
+ case "localities":
297
+ printJson(io, await adapter.searchLocalities(requiredPositional(argv, 1, "query")));
298
+ return 0;
299
+ default:
300
+ throw new Error(`Unknown command "${command}"`);
301
+ }
302
+ } catch (error) {
303
+ io.stderr.write(`${oneLineError(error)}
304
+ `);
305
+ return 1;
306
+ }
307
+ }
308
+ function parseSearchArgs(argv) {
309
+ const parsed = parseArgs({
310
+ args: argv,
311
+ allowPositionals: true,
312
+ options: {
313
+ category: { type: "string" },
314
+ min: { type: "string" },
315
+ max: { type: "string" },
316
+ free: { type: "boolean" },
317
+ location: { type: "string" },
318
+ radius: { type: "string" },
319
+ sort: { type: "string" },
320
+ asc: { type: "boolean" },
321
+ cursor: { type: "string" },
322
+ limit: { type: "string" }
323
+ }
324
+ });
325
+ const query = parsed.positionals[0];
326
+ if (!query) {
327
+ throw new Error("Missing required search query");
328
+ }
329
+ ensureNoExtraPositionals(parsed.positionals, 1);
330
+ const sort = optionalEnum(parsed.values.sort, ["timestamp"], "sort");
331
+ return {
332
+ query,
333
+ categoryId: parsed.values.category,
334
+ priceMin: optionalNumber(parsed.values.min, "min"),
335
+ priceMax: optionalNumber(parsed.values.max, "max"),
336
+ freeOnly: parsed.values.free,
337
+ location: parsed.values.location,
338
+ radiusKm: optionalNumber(parsed.values.radius, "radius"),
339
+ sort,
340
+ direction: parsed.values.asc ? "asc" : void 0,
341
+ cursor: parsed.values.cursor,
342
+ limit: optionalInteger(parsed.values.limit, "limit")
343
+ };
344
+ }
345
+ function printJson(io, value) {
346
+ io.stdout.write(`${JSON.stringify(value, null, io.stdout.isTTY ? 2 : 0)}
347
+ `);
348
+ }
349
+ function requiredPositional(argv, index, name) {
350
+ const value = argv[index];
351
+ if (!value) {
352
+ throw new Error(`Missing required ${name}`);
353
+ }
354
+ ensureNoExtraPositionals(argv, index + 1);
355
+ return value;
356
+ }
357
+ function ensureNoExtraPositionals(positionals, expectedCount) {
358
+ if (positionals.length > expectedCount) {
359
+ throw new Error(`Unexpected argument "${positionals[expectedCount]}"`);
360
+ }
361
+ }
362
+ function optionalNumber(value, name) {
363
+ if (value === void 0) {
364
+ return void 0;
365
+ }
366
+ if (typeof value !== "string") {
367
+ throw new Error(`--${name} requires a number`);
368
+ }
369
+ const number = Number(value);
370
+ if (!Number.isFinite(number)) {
371
+ throw new Error(`--${name} requires a number`);
372
+ }
373
+ return number;
374
+ }
375
+ function optionalInteger(value, name) {
376
+ const number = optionalNumber(value, name);
377
+ if (number !== void 0 && !Number.isInteger(number)) {
378
+ throw new Error(`--${name} requires an integer`);
379
+ }
380
+ return number;
381
+ }
382
+ function optionalEnum(value, allowed, name) {
383
+ if (value === void 0) {
384
+ return void 0;
385
+ }
386
+ if (typeof value !== "string" || !allowed.includes(value)) {
387
+ throw new Error(`--${name} must be one of: ${allowed.join(", ")}`);
388
+ }
389
+ return value;
390
+ }
391
+ function oneLineError(error) {
392
+ const message = error instanceof Error ? error.message : String(error);
393
+ return message.split(/\r?\n/, 1)[0] || "Unknown error";
394
+ }
395
+ function readVersion() {
396
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
397
+ return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
398
+ }
399
+ if (import.meta.url === `file://${process.argv[1]}`) {
400
+ const code = await runCli();
401
+ process.exitCode = code;
402
+ }
403
+ export {
404
+ runCli
405
+ };
406
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts","../src/core/client.ts","../src/core/mapping.ts","../src/core/throttle.ts"],"sourcesContent":["import { readFileSync } from \"node:fs\";\nimport { parseArgs } from \"node:util\";\nimport { createTuttiAdapter } from \"./core/client.js\";\nimport type { TuttiAdapter, TuttiSearchParams } from \"./core/types.js\";\n\ninterface CliIO {\n stdout: { write(chunk: string): void; isTTY?: boolean };\n stderr: { write(chunk: string): void };\n}\n\nconst HELP = `Usage:\n tutti search <query> [--category id] [--min n] [--max n] [--free] [--location name] [--radius km] [--sort timestamp] [--asc] [--cursor c] [--limit n]\n tutti get <listingId>\n tutti categories\n tutti localities <query>\n tutti --help\n tutti --version`;\n\nexport async function runCli(\n argv = process.argv.slice(2),\n adapter: TuttiAdapter = createTuttiAdapter(),\n io: CliIO = { stdout: process.stdout, stderr: process.stderr },\n version = readVersion()\n): Promise<number> {\n try {\n const command = argv[0];\n\n if (!command || command === \"--help\" || command === \"-h\") {\n io.stdout.write(`${HELP}\\n`);\n return 0;\n }\n\n if (command === \"--version\" || command === \"-v\") {\n io.stdout.write(`${version}\\n`);\n return 0;\n }\n\n switch (command) {\n case \"search\":\n printJson(io, await adapter.search(parseSearchArgs(argv.slice(1))));\n return 0;\n case \"get\":\n printJson(io, await adapter.getListing(requiredPositional(argv, 1, \"listingId\")));\n return 0;\n case \"categories\":\n ensureNoExtraPositionals(argv, 1);\n printJson(io, await adapter.getCategories());\n return 0;\n case \"localities\":\n printJson(io, await adapter.searchLocalities(requiredPositional(argv, 1, \"query\")));\n return 0;\n default:\n throw new Error(`Unknown command \"${command}\"`);\n }\n } catch (error) {\n io.stderr.write(`${oneLineError(error)}\\n`);\n return 1;\n }\n}\n\nfunction parseSearchArgs(argv: string[]): TuttiSearchParams {\n const parsed = parseArgs({\n args: argv,\n allowPositionals: true,\n options: {\n category: { type: \"string\" },\n min: { type: \"string\" },\n max: { type: \"string\" },\n free: { type: \"boolean\" },\n location: { type: \"string\" },\n radius: { type: \"string\" },\n sort: { type: \"string\" },\n asc: { type: \"boolean\" },\n cursor: { type: \"string\" },\n limit: { type: \"string\" }\n }\n });\n\n const query = parsed.positionals[0];\n if (!query) {\n throw new Error(\"Missing required search query\");\n }\n\n ensureNoExtraPositionals(parsed.positionals, 1);\n\n const sort = optionalEnum(parsed.values.sort, [\"timestamp\"], \"sort\");\n\n return {\n query,\n categoryId: parsed.values.category,\n priceMin: optionalNumber(parsed.values.min, \"min\"),\n priceMax: optionalNumber(parsed.values.max, \"max\"),\n freeOnly: parsed.values.free,\n location: parsed.values.location,\n radiusKm: optionalNumber(parsed.values.radius, \"radius\"),\n sort,\n direction: parsed.values.asc ? \"asc\" : undefined,\n cursor: parsed.values.cursor,\n limit: optionalInteger(parsed.values.limit, \"limit\")\n };\n}\n\nfunction printJson(io: CliIO, value: unknown): void {\n io.stdout.write(`${JSON.stringify(value, null, io.stdout.isTTY ? 2 : 0)}\\n`);\n}\n\nfunction requiredPositional(argv: string[], index: number, name: string): string {\n const value = argv[index];\n if (!value) {\n throw new Error(`Missing required ${name}`);\n }\n\n ensureNoExtraPositionals(argv, index + 1);\n return value;\n}\n\nfunction ensureNoExtraPositionals(positionals: string[], expectedCount: number): void {\n if (positionals.length > expectedCount) {\n throw new Error(`Unexpected argument \"${positionals[expectedCount]}\"`);\n }\n}\n\nfunction optionalNumber(value: string | boolean | undefined, name: string): number | undefined {\n if (value === undefined) {\n return undefined;\n }\n\n if (typeof value !== \"string\") {\n throw new Error(`--${name} requires a number`);\n }\n\n const number = Number(value);\n if (!Number.isFinite(number)) {\n throw new Error(`--${name} requires a number`);\n }\n\n return number;\n}\n\nfunction optionalInteger(value: string | boolean | undefined, name: string): number | undefined {\n const number = optionalNumber(value, name);\n if (number !== undefined && !Number.isInteger(number)) {\n throw new Error(`--${name} requires an integer`);\n }\n\n return number;\n}\n\nfunction optionalEnum<T extends string>(value: string | boolean | undefined, allowed: readonly T[], name: string): T | undefined {\n if (value === undefined) {\n return undefined;\n }\n\n if (typeof value !== \"string\" || !allowed.includes(value as T)) {\n throw new Error(`--${name} must be one of: ${allowed.join(\", \")}`);\n }\n\n return value as T;\n}\n\nfunction oneLineError(error: unknown): string {\n const message = error instanceof Error ? error.message : String(error);\n return message.split(/\\r?\\n/, 1)[0] || \"Unknown error\";\n}\n\nfunction readVersion(): string {\n const packageJson = JSON.parse(readFileSync(new URL(\"../package.json\", import.meta.url), \"utf8\")) as { version?: unknown };\n return typeof packageJson.version === \"string\" ? packageJson.version : \"0.0.0\";\n}\n\nif (import.meta.url === `file://${process.argv[1]}`) {\n const code = await runCli();\n process.exitCode = code;\n}\n","import { TuttiClient, type Locality, type SearchBuilder } from \"tutti-api\";\nimport { mapCategoryTree, mapListingDetail, mapListingSummary, mapLocality } from \"./mapping.js\";\nimport { runWithThrottleAndRetry, type ThrottledRetry } from \"./throttle.js\";\nimport type { CategoryNode, LocalitySummary, SearchResultPage, TuttiAdapter, TuttiSearchParams } from \"./types.js\";\n\nlet singleton: TuttiClient | null = null;\n\nfunction getClient(): TuttiClient {\n singleton ??= new TuttiClient();\n return singleton;\n}\n\nexport function createTuttiAdapter(client = getClient(), run: ThrottledRetry = runWithThrottleAndRetry): TuttiAdapter {\n return {\n async search(params: TuttiSearchParams): Promise<SearchResultPage> {\n const limit = normalizeLimit(params.limit);\n let resolvedLocation: Locality | undefined;\n let builder = client.search(params.query);\n\n if (params.categoryId) {\n builder = builder.category(params.categoryId);\n }\n\n if (params.freeOnly) {\n builder = builder.freeOnly();\n } else if (params.priceMin !== undefined || params.priceMax !== undefined) {\n builder = builder.price({ min: params.priceMin, max: params.priceMax });\n }\n\n if (params.location) {\n const matches = await run(() => client.localities.search(params.location ?? \"\"));\n resolvedLocation = matches[0];\n if (!resolvedLocation) {\n throw new Error(`No locality matched \"${params.location}\" - try search_localities`);\n }\n builder = builder.location(resolvedLocation);\n\n if (params.radiusKm !== undefined) {\n builder = builder.radius(params.radiusKm);\n }\n }\n\n if (params.sort) {\n builder = builder.sort(params.sort, params.direction ?? \"desc\");\n } else if (params.direction) {\n builder = builder.sort(\"timestamp\", params.direction);\n }\n\n if (params.cursor) {\n builder = builder.cursor(params.cursor);\n }\n\n const page = await run(() => builder.fetch());\n\n return {\n totalCount: page.totalCount,\n resolvedLocation: resolvedLocation?.name,\n listings: page.listings.slice(0, limit).map(mapListingSummary),\n nextCursor: page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null\n };\n },\n\n async getListing(id: string) {\n return mapListingDetail(await run(() => client.listings.get(id)));\n },\n\n async getCategories(): Promise<CategoryNode[]> {\n return mapCategoryTree(await run(() => client.categories.tree()));\n },\n\n async searchLocalities(text: string): Promise<LocalitySummary[]> {\n return (await run(() => client.localities.search(text))).map(mapLocality);\n }\n };\n}\n\nfunction normalizeLimit(limit: number | undefined): number {\n if (limit === undefined) {\n return 15;\n }\n\n if (!Number.isInteger(limit) || limit < 1 || limit > 30) {\n throw new Error(\"limit must be an integer between 1 and 30\");\n }\n\n return limit;\n}\n\nexport type { SearchBuilder };\n","import type { Listing, Locality } from \"tutti-api\";\nimport type { CategoryNode, ListingDetail, ListingSummary, LocalitySummary } from \"./types.js\";\n\nconst LISTING_URL_BASE = \"https://www.tutti.ch/de/vi\";\n\nexport function buildListingUrl(id: string): string {\n return `${LISTING_URL_BASE}/${encodeURIComponent(id)}`;\n}\n\nexport function formatLocation(listing: Pick<Listing, \"postcodeInformation\" | \"address\">): string | null {\n const info = listing.postcodeInformation;\n const place = [info?.postcode, info?.locationName].filter(Boolean).join(\" \").trim();\n const canton = info?.canton?.shortName;\n\n if (place && canton) {\n return `${place} (${canton})`;\n }\n\n return place || listing.address || null;\n}\n\nexport function mapListingSummary(listing: Listing): ListingSummary {\n return {\n id: listing.listingID,\n title: listing.title,\n price: listing.formattedPrice ?? null,\n location: formatLocation(listing),\n category: listing.primaryCategory?.label ?? null,\n timestamp: listing.timestamp ?? null,\n url: buildListingUrl(listing.listingID)\n };\n}\n\nexport function mapListingDetail(listing: Listing): ListingDetail {\n return {\n ...mapListingSummary(listing),\n description: stringField(listing.body),\n seller: listing.sellerInfo?.publicAccountID\n ? {\n id: listing.sellerInfo.publicAccountID,\n alias: listing.sellerInfo.alias ?? null\n }\n : null,\n images: imageUrls(listing),\n address: listing.address ?? null,\n source: listing.formattedSource ?? null,\n language: stringField(listing.language)\n };\n}\n\nexport function mapLocality(locality: Locality): LocalitySummary {\n return {\n id: locality.localityID,\n name: locality.name,\n type: locality.localityType ?? null\n };\n}\n\nexport function mapCategoryTree(raw: unknown): CategoryNode[] {\n const candidates = collectCategoryArrays(raw);\n const best = candidates.sort((a, b) => b.length - a.length)[0] ?? [];\n return best.map(mapCategoryNode).filter((node): node is CategoryNode => node !== null);\n}\n\nfunction mapCategoryNode(raw: unknown): CategoryNode | null {\n if (!isRecord(raw)) {\n return null;\n }\n\n const id = stringField(raw.categoryID ?? raw.id);\n const label = stringField(raw.label ?? raw.name);\n if (!id || !label) {\n return null;\n }\n\n const children = arrayField(raw.children ?? raw.subcategories ?? raw.childCategories)\n .map(mapCategoryNode)\n .filter((node): node is CategoryNode => node !== null);\n\n return { id, label, children };\n}\n\nfunction collectCategoryArrays(raw: unknown): unknown[][] {\n const arrays: unknown[][] = [];\n\n function walk(value: unknown): void {\n if (Array.isArray(value)) {\n if (value.some(looksLikeCategory)) {\n arrays.push(value);\n }\n for (const item of value) {\n walk(item);\n }\n return;\n }\n\n if (isRecord(value)) {\n for (const child of Object.values(value)) {\n walk(child);\n }\n }\n }\n\n walk(raw);\n return arrays;\n}\n\nfunction looksLikeCategory(value: unknown): boolean {\n return isRecord(value) && typeof (value.categoryID ?? value.id) === \"string\" && typeof (value.label ?? value.name) === \"string\";\n}\n\nfunction imageUrls(listing: Listing): string[] {\n const images = arrayField(listing.images)\n .map((image) => (isRecord(image) && isRecord(image.rendition) ? stringField(image.rendition.src) : null))\n .filter((src): src is string => src !== null);\n\n if (images.length > 0) {\n return images.slice(0, 5);\n }\n\n const thumbnail = listing.thumbnail?.rendition?.src;\n return thumbnail ? [thumbnail] : [];\n}\n\nfunction arrayField(value: unknown): unknown[] {\n return Array.isArray(value) ? value : [];\n}\n\nfunction stringField(value: unknown): string | null {\n return typeof value === \"string\" && value.length > 0 ? value : null;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n","export interface ThrottleRetryOptions {\n minDelayMs?: number;\n retryDelaysMs?: readonly number[];\n now?: () => number;\n sleep?: (ms: number) => Promise<void>;\n}\n\nexport type ThrottledRetry = <T>(operation: () => Promise<T>) => Promise<T>;\n\nconst DEFAULT_MIN_DELAY_MS = 700;\nconst DEFAULT_RETRY_DELAYS_MS = [1000, 3000] as const;\n\nexport const runWithThrottleAndRetry = createThrottledRetry();\n\nexport function createThrottledRetry(options: ThrottleRetryOptions = {}): ThrottledRetry {\n const minDelayMs = options.minDelayMs ?? DEFAULT_MIN_DELAY_MS;\n const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;\n const now = options.now ?? Date.now;\n const sleep = options.sleep ?? defaultSleep;\n let previous = Promise.resolve();\n let lastStartedAt: number | null = null;\n\n return async function throttledRetry<T>(operation: () => Promise<T>): Promise<T> {\n const runAfterPrevious = previous.catch(() => undefined);\n let release: () => void = () => undefined;\n previous = runAfterPrevious.then(() => new Promise<void>((resolve) => {\n release = resolve;\n }));\n\n await runAfterPrevious;\n\n try {\n for (let attempt = 0; ; attempt += 1) {\n if (lastStartedAt !== null) {\n const waitMs = minDelayMs - (now() - lastStartedAt);\n if (waitMs > 0) {\n await sleep(waitMs);\n }\n }\n\n lastStartedAt = now();\n\n try {\n return await operation();\n } catch (error) {\n if (!shouldRetry(error) || attempt >= retryDelaysMs.length) {\n throw normalizeError(error);\n }\n\n await sleep(retryDelaysMs[attempt]);\n }\n }\n } finally {\n release();\n }\n };\n}\n\nexport function shouldRetry(error: unknown): boolean {\n const status = statusFromError(error);\n\n if (status === undefined) {\n return isNetworkError(error);\n }\n\n if (isCloudflareBlock(error)) {\n return false;\n }\n\n return status === 429 || status >= 500;\n}\n\nexport function normalizeError(error: unknown): Error {\n if (isCloudflareBlock(error)) {\n return new Error(\"tutti.ch is rate-limiting this client; wait a few minutes\");\n }\n\n if (error instanceof Error) {\n return error;\n }\n\n return new Error(String(error));\n}\n\nfunction statusFromError(error: unknown): number | undefined {\n return isRecord(error) && typeof error.status === \"number\" ? error.status : undefined;\n}\n\nfunction isNetworkError(error: unknown): boolean {\n return error instanceof Error && (error.name === \"TuttiNetworkError\" || error.name === \"TypeError\");\n}\n\nfunction isCloudflareBlock(error: unknown): boolean {\n if (!isRecord(error) || error.status !== 403 || typeof error.body !== \"string\") {\n return false;\n }\n\n return /<html|<!doctype html/i.test(error.body);\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n\nfunction defaultSleep(ms: number): Promise<void> {\n return new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n}\n"],"mappings":"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","names":["isRecord"]}
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
+ import { T as TuttiAdapter } from './types-CqLkHDpv.js';
4
+
5
+ declare function createMcpServer(adapter?: TuttiAdapter, version?: string): McpServer;
6
+ declare function runMcpServer(adapter?: TuttiAdapter): Promise<void>;
7
+ declare function toolResult<T>(operation: () => Promise<T>): Promise<CallToolResult>;
8
+
9
+ export { createMcpServer, runMcpServer, toolResult };
package/dist/mcp.js ADDED
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/mcp.ts
4
+ import { readFileSync } from "fs";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { z } from "zod";
8
+
9
+ // src/core/client.ts
10
+ import { TuttiClient } from "tutti-api";
11
+
12
+ // src/core/mapping.ts
13
+ var LISTING_URL_BASE = "https://www.tutti.ch/de/vi";
14
+ function buildListingUrl(id) {
15
+ return `${LISTING_URL_BASE}/${encodeURIComponent(id)}`;
16
+ }
17
+ function formatLocation(listing) {
18
+ const info = listing.postcodeInformation;
19
+ const place = [info?.postcode, info?.locationName].filter(Boolean).join(" ").trim();
20
+ const canton = info?.canton?.shortName;
21
+ if (place && canton) {
22
+ return `${place} (${canton})`;
23
+ }
24
+ return place || listing.address || null;
25
+ }
26
+ function mapListingSummary(listing) {
27
+ return {
28
+ id: listing.listingID,
29
+ title: listing.title,
30
+ price: listing.formattedPrice ?? null,
31
+ location: formatLocation(listing),
32
+ category: listing.primaryCategory?.label ?? null,
33
+ timestamp: listing.timestamp ?? null,
34
+ url: buildListingUrl(listing.listingID)
35
+ };
36
+ }
37
+ function mapListingDetail(listing) {
38
+ return {
39
+ ...mapListingSummary(listing),
40
+ description: stringField(listing.body),
41
+ seller: listing.sellerInfo?.publicAccountID ? {
42
+ id: listing.sellerInfo.publicAccountID,
43
+ alias: listing.sellerInfo.alias ?? null
44
+ } : null,
45
+ images: imageUrls(listing),
46
+ address: listing.address ?? null,
47
+ source: listing.formattedSource ?? null,
48
+ language: stringField(listing.language)
49
+ };
50
+ }
51
+ function mapLocality(locality) {
52
+ return {
53
+ id: locality.localityID,
54
+ name: locality.name,
55
+ type: locality.localityType ?? null
56
+ };
57
+ }
58
+ function mapCategoryTree(raw) {
59
+ const candidates = collectCategoryArrays(raw);
60
+ const best = candidates.sort((a, b) => b.length - a.length)[0] ?? [];
61
+ return best.map(mapCategoryNode).filter((node) => node !== null);
62
+ }
63
+ function mapCategoryNode(raw) {
64
+ if (!isRecord(raw)) {
65
+ return null;
66
+ }
67
+ const id = stringField(raw.categoryID ?? raw.id);
68
+ const label = stringField(raw.label ?? raw.name);
69
+ if (!id || !label) {
70
+ return null;
71
+ }
72
+ const children = arrayField(raw.children ?? raw.subcategories ?? raw.childCategories).map(mapCategoryNode).filter((node) => node !== null);
73
+ return { id, label, children };
74
+ }
75
+ function collectCategoryArrays(raw) {
76
+ const arrays = [];
77
+ function walk(value) {
78
+ if (Array.isArray(value)) {
79
+ if (value.some(looksLikeCategory)) {
80
+ arrays.push(value);
81
+ }
82
+ for (const item of value) {
83
+ walk(item);
84
+ }
85
+ return;
86
+ }
87
+ if (isRecord(value)) {
88
+ for (const child of Object.values(value)) {
89
+ walk(child);
90
+ }
91
+ }
92
+ }
93
+ walk(raw);
94
+ return arrays;
95
+ }
96
+ function looksLikeCategory(value) {
97
+ return isRecord(value) && typeof (value.categoryID ?? value.id) === "string" && typeof (value.label ?? value.name) === "string";
98
+ }
99
+ function imageUrls(listing) {
100
+ const images = arrayField(listing.images).map((image) => isRecord(image) && isRecord(image.rendition) ? stringField(image.rendition.src) : null).filter((src) => src !== null);
101
+ if (images.length > 0) {
102
+ return images.slice(0, 5);
103
+ }
104
+ const thumbnail = listing.thumbnail?.rendition?.src;
105
+ return thumbnail ? [thumbnail] : [];
106
+ }
107
+ function arrayField(value) {
108
+ return Array.isArray(value) ? value : [];
109
+ }
110
+ function stringField(value) {
111
+ return typeof value === "string" && value.length > 0 ? value : null;
112
+ }
113
+ function isRecord(value) {
114
+ return typeof value === "object" && value !== null;
115
+ }
116
+
117
+ // src/core/throttle.ts
118
+ var DEFAULT_MIN_DELAY_MS = 700;
119
+ var DEFAULT_RETRY_DELAYS_MS = [1e3, 3e3];
120
+ var runWithThrottleAndRetry = createThrottledRetry();
121
+ function createThrottledRetry(options = {}) {
122
+ const minDelayMs = options.minDelayMs ?? DEFAULT_MIN_DELAY_MS;
123
+ const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
124
+ const now = options.now ?? Date.now;
125
+ const sleep = options.sleep ?? defaultSleep;
126
+ let previous = Promise.resolve();
127
+ let lastStartedAt = null;
128
+ return async function throttledRetry(operation) {
129
+ const runAfterPrevious = previous.catch(() => void 0);
130
+ let release = () => void 0;
131
+ previous = runAfterPrevious.then(() => new Promise((resolve) => {
132
+ release = resolve;
133
+ }));
134
+ await runAfterPrevious;
135
+ try {
136
+ for (let attempt = 0; ; attempt += 1) {
137
+ if (lastStartedAt !== null) {
138
+ const waitMs = minDelayMs - (now() - lastStartedAt);
139
+ if (waitMs > 0) {
140
+ await sleep(waitMs);
141
+ }
142
+ }
143
+ lastStartedAt = now();
144
+ try {
145
+ return await operation();
146
+ } catch (error) {
147
+ if (!shouldRetry(error) || attempt >= retryDelaysMs.length) {
148
+ throw normalizeError(error);
149
+ }
150
+ await sleep(retryDelaysMs[attempt]);
151
+ }
152
+ }
153
+ } finally {
154
+ release();
155
+ }
156
+ };
157
+ }
158
+ function shouldRetry(error) {
159
+ const status = statusFromError(error);
160
+ if (status === void 0) {
161
+ return isNetworkError(error);
162
+ }
163
+ if (isCloudflareBlock(error)) {
164
+ return false;
165
+ }
166
+ return status === 429 || status >= 500;
167
+ }
168
+ function normalizeError(error) {
169
+ if (isCloudflareBlock(error)) {
170
+ return new Error("tutti.ch is rate-limiting this client; wait a few minutes");
171
+ }
172
+ if (error instanceof Error) {
173
+ return error;
174
+ }
175
+ return new Error(String(error));
176
+ }
177
+ function statusFromError(error) {
178
+ return isRecord2(error) && typeof error.status === "number" ? error.status : void 0;
179
+ }
180
+ function isNetworkError(error) {
181
+ return error instanceof Error && (error.name === "TuttiNetworkError" || error.name === "TypeError");
182
+ }
183
+ function isCloudflareBlock(error) {
184
+ if (!isRecord2(error) || error.status !== 403 || typeof error.body !== "string") {
185
+ return false;
186
+ }
187
+ return /<html|<!doctype html/i.test(error.body);
188
+ }
189
+ function isRecord2(value) {
190
+ return typeof value === "object" && value !== null;
191
+ }
192
+ function defaultSleep(ms) {
193
+ return new Promise((resolve) => {
194
+ setTimeout(resolve, ms);
195
+ });
196
+ }
197
+
198
+ // src/core/client.ts
199
+ var singleton = null;
200
+ function getClient() {
201
+ singleton ??= new TuttiClient();
202
+ return singleton;
203
+ }
204
+ function createTuttiAdapter(client = getClient(), run = runWithThrottleAndRetry) {
205
+ return {
206
+ async search(params) {
207
+ const limit = normalizeLimit(params.limit);
208
+ let resolvedLocation;
209
+ let builder = client.search(params.query);
210
+ if (params.categoryId) {
211
+ builder = builder.category(params.categoryId);
212
+ }
213
+ if (params.freeOnly) {
214
+ builder = builder.freeOnly();
215
+ } else if (params.priceMin !== void 0 || params.priceMax !== void 0) {
216
+ builder = builder.price({ min: params.priceMin, max: params.priceMax });
217
+ }
218
+ if (params.location) {
219
+ const matches = await run(() => client.localities.search(params.location ?? ""));
220
+ resolvedLocation = matches[0];
221
+ if (!resolvedLocation) {
222
+ throw new Error(`No locality matched "${params.location}" - try search_localities`);
223
+ }
224
+ builder = builder.location(resolvedLocation);
225
+ if (params.radiusKm !== void 0) {
226
+ builder = builder.radius(params.radiusKm);
227
+ }
228
+ }
229
+ if (params.sort) {
230
+ builder = builder.sort(params.sort, params.direction ?? "desc");
231
+ } else if (params.direction) {
232
+ builder = builder.sort("timestamp", params.direction);
233
+ }
234
+ if (params.cursor) {
235
+ builder = builder.cursor(params.cursor);
236
+ }
237
+ const page = await run(() => builder.fetch());
238
+ return {
239
+ totalCount: page.totalCount,
240
+ resolvedLocation: resolvedLocation?.name,
241
+ listings: page.listings.slice(0, limit).map(mapListingSummary),
242
+ nextCursor: page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null
243
+ };
244
+ },
245
+ async getListing(id) {
246
+ return mapListingDetail(await run(() => client.listings.get(id)));
247
+ },
248
+ async getCategories() {
249
+ return mapCategoryTree(await run(() => client.categories.tree()));
250
+ },
251
+ async searchLocalities(text) {
252
+ return (await run(() => client.localities.search(text))).map(mapLocality);
253
+ }
254
+ };
255
+ }
256
+ function normalizeLimit(limit) {
257
+ if (limit === void 0) {
258
+ return 15;
259
+ }
260
+ if (!Number.isInteger(limit) || limit < 1 || limit > 30) {
261
+ throw new Error("limit must be an integer between 1 and 30");
262
+ }
263
+ return limit;
264
+ }
265
+
266
+ // src/mcp.ts
267
+ var searchInputSchema = {
268
+ query: z.string().min(1).optional(),
269
+ categoryId: z.string().min(1).optional(),
270
+ priceMin: z.number().nonnegative().optional(),
271
+ priceMax: z.number().nonnegative().optional(),
272
+ freeOnly: z.boolean().optional(),
273
+ location: z.string().min(1).optional(),
274
+ radiusKm: z.number().positive().optional(),
275
+ sort: z.enum(["timestamp"]).optional(),
276
+ direction: z.enum(["asc", "desc"]).optional(),
277
+ cursor: z.string().min(1).optional(),
278
+ limit: z.number().int().min(1).max(30).optional()
279
+ };
280
+ var listingInputSchema = {
281
+ id: z.string().min(1)
282
+ };
283
+ var localityInputSchema = {
284
+ query: z.string().min(1)
285
+ };
286
+ function createMcpServer(adapter = createTuttiAdapter(), version = readPackageVersion()) {
287
+ const server = new McpServer({ name: "tutti", version });
288
+ server.registerTool(
289
+ "search_listings",
290
+ {
291
+ title: "Search tutti.ch listings",
292
+ description: "Search anonymous read-only tutti.ch listings. Prices are CHF. Use nextCursor as the cursor parameter for the next page. The location parameter accepts a Swiss place name and resolves to the top match; use search_localities first when ambiguous. Results include tutti.ch listing URLs.",
293
+ inputSchema: searchInputSchema
294
+ },
295
+ async (params) => toolResult(() => adapter.search(params))
296
+ );
297
+ server.registerTool(
298
+ "get_listing",
299
+ {
300
+ title: "Get a tutti.ch listing",
301
+ description: "Fetch one tutti.ch listing by id. Returns compact listing detail with description, seller, up to five image URLs, and the canonical tutti.ch URL.",
302
+ inputSchema: listingInputSchema
303
+ },
304
+ async ({ id }) => toolResult(() => adapter.getListing(id))
305
+ );
306
+ server.registerTool(
307
+ "get_categories",
308
+ {
309
+ title: "Get tutti.ch categories",
310
+ description: "Return the tutti.ch category tree as compact { id, label, children } nodes. Use ids as categoryId values when searching listings.",
311
+ inputSchema: {}
312
+ },
313
+ async () => toolResult(() => adapter.getCategories())
314
+ );
315
+ server.registerTool(
316
+ "search_localities",
317
+ {
318
+ title: "Search Swiss localities",
319
+ description: "Search Swiss locality names for tutti.ch location filters. Use this to disambiguate place names before search_listings; search_listings resolves location text to the top match.",
320
+ inputSchema: localityInputSchema
321
+ },
322
+ async ({ query }) => toolResult(() => adapter.searchLocalities(query))
323
+ );
324
+ return server;
325
+ }
326
+ async function runMcpServer(adapter = createTuttiAdapter()) {
327
+ const server = createMcpServer(adapter);
328
+ const transport = new StdioServerTransport();
329
+ await server.connect(transport);
330
+ }
331
+ async function toolResult(operation) {
332
+ try {
333
+ const payload = await operation();
334
+ return {
335
+ structuredContent: toStructuredContent(payload),
336
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
337
+ };
338
+ } catch (error) {
339
+ return {
340
+ isError: true,
341
+ content: [{ type: "text", text: oneLineError(error) }]
342
+ };
343
+ }
344
+ }
345
+ function toStructuredContent(value) {
346
+ if (typeof value === "object" && value !== null) {
347
+ return value;
348
+ }
349
+ return { value };
350
+ }
351
+ function oneLineError(error) {
352
+ const message = error instanceof Error ? error.message : String(error);
353
+ return message.split(/\r?\n/, 1)[0] || "Unknown tutti.ch error";
354
+ }
355
+ function readPackageVersion() {
356
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
357
+ return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
358
+ }
359
+ if (import.meta.url === `file://${process.argv[1]}`) {
360
+ runMcpServer().catch((error) => {
361
+ console.error(oneLineError(error));
362
+ process.exitCode = 1;
363
+ });
364
+ }
365
+ export {
366
+ createMcpServer,
367
+ runMcpServer,
368
+ toolResult
369
+ };
370
+ //# sourceMappingURL=mcp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/mcp.ts","../src/core/client.ts","../src/core/mapping.ts","../src/core/throttle.ts"],"sourcesContent":["import { readFileSync } from \"node:fs\";\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport { z } from \"zod\";\nimport { createTuttiAdapter } from \"./core/client.js\";\nimport type { TuttiAdapter } from \"./core/types.js\";\n\nconst searchInputSchema = {\n query: z.string().min(1).optional(),\n categoryId: z.string().min(1).optional(),\n priceMin: z.number().nonnegative().optional(),\n priceMax: z.number().nonnegative().optional(),\n freeOnly: z.boolean().optional(),\n location: z.string().min(1).optional(),\n radiusKm: z.number().positive().optional(),\n sort: z.enum([\"timestamp\"]).optional(),\n direction: z.enum([\"asc\", \"desc\"]).optional(),\n cursor: z.string().min(1).optional(),\n limit: z.number().int().min(1).max(30).optional()\n};\n\nconst listingInputSchema = {\n id: z.string().min(1)\n};\n\nconst localityInputSchema = {\n query: z.string().min(1)\n};\n\nexport function createMcpServer(adapter: TuttiAdapter = createTuttiAdapter(), version = readPackageVersion()): McpServer {\n const server = new McpServer({ name: \"tutti\", version });\n\n server.registerTool(\n \"search_listings\",\n {\n title: \"Search tutti.ch listings\",\n description:\n \"Search anonymous read-only tutti.ch listings. Prices are CHF. Use nextCursor as the cursor parameter for the next page. The location parameter accepts a Swiss place name and resolves to the top match; use search_localities first when ambiguous. Results include tutti.ch listing URLs.\",\n inputSchema: searchInputSchema\n },\n async (params) => toolResult(() => adapter.search(params))\n );\n\n server.registerTool(\n \"get_listing\",\n {\n title: \"Get a tutti.ch listing\",\n description:\n \"Fetch one tutti.ch listing by id. Returns compact listing detail with description, seller, up to five image URLs, and the canonical tutti.ch URL.\",\n inputSchema: listingInputSchema\n },\n async ({ id }) => toolResult(() => adapter.getListing(id))\n );\n\n server.registerTool(\n \"get_categories\",\n {\n title: \"Get tutti.ch categories\",\n description:\n \"Return the tutti.ch category tree as compact { id, label, children } nodes. Use ids as categoryId values when searching listings.\",\n inputSchema: {}\n },\n async () => toolResult(() => adapter.getCategories())\n );\n\n server.registerTool(\n \"search_localities\",\n {\n title: \"Search Swiss localities\",\n description:\n \"Search Swiss locality names for tutti.ch location filters. Use this to disambiguate place names before search_listings; search_listings resolves location text to the top match.\",\n inputSchema: localityInputSchema\n },\n async ({ query }) => toolResult(() => adapter.searchLocalities(query))\n );\n\n return server;\n}\n\nexport async function runMcpServer(adapter: TuttiAdapter = createTuttiAdapter()): Promise<void> {\n const server = createMcpServer(adapter);\n const transport = new StdioServerTransport();\n await server.connect(transport);\n}\n\nexport async function toolResult<T>(operation: () => Promise<T>): Promise<CallToolResult> {\n try {\n const payload = await operation();\n return {\n structuredContent: toStructuredContent(payload),\n content: [{ type: \"text\" as const, text: JSON.stringify(payload, null, 2) }]\n };\n } catch (error) {\n return {\n isError: true,\n content: [{ type: \"text\" as const, text: oneLineError(error) }]\n };\n }\n}\n\nfunction toStructuredContent(value: unknown): Record<string, unknown> {\n if (typeof value === \"object\" && value !== null) {\n return value as Record<string, unknown>;\n }\n\n return { value };\n}\n\nfunction oneLineError(error: unknown): string {\n const message = error instanceof Error ? error.message : String(error);\n return message.split(/\\r?\\n/, 1)[0] || \"Unknown tutti.ch error\";\n}\n\nfunction readPackageVersion(): string {\n const packageJson = JSON.parse(readFileSync(new URL(\"../package.json\", import.meta.url), \"utf8\")) as { version?: unknown };\n return typeof packageJson.version === \"string\" ? packageJson.version : \"0.0.0\";\n}\n\nif (import.meta.url === `file://${process.argv[1]}`) {\n runMcpServer().catch((error: unknown) => {\n console.error(oneLineError(error));\n process.exitCode = 1;\n });\n}\n","import { TuttiClient, type Locality, type SearchBuilder } from \"tutti-api\";\nimport { mapCategoryTree, mapListingDetail, mapListingSummary, mapLocality } from \"./mapping.js\";\nimport { runWithThrottleAndRetry, type ThrottledRetry } from \"./throttle.js\";\nimport type { CategoryNode, LocalitySummary, SearchResultPage, TuttiAdapter, TuttiSearchParams } from \"./types.js\";\n\nlet singleton: TuttiClient | null = null;\n\nfunction getClient(): TuttiClient {\n singleton ??= new TuttiClient();\n return singleton;\n}\n\nexport function createTuttiAdapter(client = getClient(), run: ThrottledRetry = runWithThrottleAndRetry): TuttiAdapter {\n return {\n async search(params: TuttiSearchParams): Promise<SearchResultPage> {\n const limit = normalizeLimit(params.limit);\n let resolvedLocation: Locality | undefined;\n let builder = client.search(params.query);\n\n if (params.categoryId) {\n builder = builder.category(params.categoryId);\n }\n\n if (params.freeOnly) {\n builder = builder.freeOnly();\n } else if (params.priceMin !== undefined || params.priceMax !== undefined) {\n builder = builder.price({ min: params.priceMin, max: params.priceMax });\n }\n\n if (params.location) {\n const matches = await run(() => client.localities.search(params.location ?? \"\"));\n resolvedLocation = matches[0];\n if (!resolvedLocation) {\n throw new Error(`No locality matched \"${params.location}\" - try search_localities`);\n }\n builder = builder.location(resolvedLocation);\n\n if (params.radiusKm !== undefined) {\n builder = builder.radius(params.radiusKm);\n }\n }\n\n if (params.sort) {\n builder = builder.sort(params.sort, params.direction ?? \"desc\");\n } else if (params.direction) {\n builder = builder.sort(\"timestamp\", params.direction);\n }\n\n if (params.cursor) {\n builder = builder.cursor(params.cursor);\n }\n\n const page = await run(() => builder.fetch());\n\n return {\n totalCount: page.totalCount,\n resolvedLocation: resolvedLocation?.name,\n listings: page.listings.slice(0, limit).map(mapListingSummary),\n nextCursor: page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null\n };\n },\n\n async getListing(id: string) {\n return mapListingDetail(await run(() => client.listings.get(id)));\n },\n\n async getCategories(): Promise<CategoryNode[]> {\n return mapCategoryTree(await run(() => client.categories.tree()));\n },\n\n async searchLocalities(text: string): Promise<LocalitySummary[]> {\n return (await run(() => client.localities.search(text))).map(mapLocality);\n }\n };\n}\n\nfunction normalizeLimit(limit: number | undefined): number {\n if (limit === undefined) {\n return 15;\n }\n\n if (!Number.isInteger(limit) || limit < 1 || limit > 30) {\n throw new Error(\"limit must be an integer between 1 and 30\");\n }\n\n return limit;\n}\n\nexport type { SearchBuilder };\n","import type { Listing, Locality } from \"tutti-api\";\nimport type { CategoryNode, ListingDetail, ListingSummary, LocalitySummary } from \"./types.js\";\n\nconst LISTING_URL_BASE = \"https://www.tutti.ch/de/vi\";\n\nexport function buildListingUrl(id: string): string {\n return `${LISTING_URL_BASE}/${encodeURIComponent(id)}`;\n}\n\nexport function formatLocation(listing: Pick<Listing, \"postcodeInformation\" | \"address\">): string | null {\n const info = listing.postcodeInformation;\n const place = [info?.postcode, info?.locationName].filter(Boolean).join(\" \").trim();\n const canton = info?.canton?.shortName;\n\n if (place && canton) {\n return `${place} (${canton})`;\n }\n\n return place || listing.address || null;\n}\n\nexport function mapListingSummary(listing: Listing): ListingSummary {\n return {\n id: listing.listingID,\n title: listing.title,\n price: listing.formattedPrice ?? null,\n location: formatLocation(listing),\n category: listing.primaryCategory?.label ?? null,\n timestamp: listing.timestamp ?? null,\n url: buildListingUrl(listing.listingID)\n };\n}\n\nexport function mapListingDetail(listing: Listing): ListingDetail {\n return {\n ...mapListingSummary(listing),\n description: stringField(listing.body),\n seller: listing.sellerInfo?.publicAccountID\n ? {\n id: listing.sellerInfo.publicAccountID,\n alias: listing.sellerInfo.alias ?? null\n }\n : null,\n images: imageUrls(listing),\n address: listing.address ?? null,\n source: listing.formattedSource ?? null,\n language: stringField(listing.language)\n };\n}\n\nexport function mapLocality(locality: Locality): LocalitySummary {\n return {\n id: locality.localityID,\n name: locality.name,\n type: locality.localityType ?? null\n };\n}\n\nexport function mapCategoryTree(raw: unknown): CategoryNode[] {\n const candidates = collectCategoryArrays(raw);\n const best = candidates.sort((a, b) => b.length - a.length)[0] ?? [];\n return best.map(mapCategoryNode).filter((node): node is CategoryNode => node !== null);\n}\n\nfunction mapCategoryNode(raw: unknown): CategoryNode | null {\n if (!isRecord(raw)) {\n return null;\n }\n\n const id = stringField(raw.categoryID ?? raw.id);\n const label = stringField(raw.label ?? raw.name);\n if (!id || !label) {\n return null;\n }\n\n const children = arrayField(raw.children ?? raw.subcategories ?? raw.childCategories)\n .map(mapCategoryNode)\n .filter((node): node is CategoryNode => node !== null);\n\n return { id, label, children };\n}\n\nfunction collectCategoryArrays(raw: unknown): unknown[][] {\n const arrays: unknown[][] = [];\n\n function walk(value: unknown): void {\n if (Array.isArray(value)) {\n if (value.some(looksLikeCategory)) {\n arrays.push(value);\n }\n for (const item of value) {\n walk(item);\n }\n return;\n }\n\n if (isRecord(value)) {\n for (const child of Object.values(value)) {\n walk(child);\n }\n }\n }\n\n walk(raw);\n return arrays;\n}\n\nfunction looksLikeCategory(value: unknown): boolean {\n return isRecord(value) && typeof (value.categoryID ?? value.id) === \"string\" && typeof (value.label ?? value.name) === \"string\";\n}\n\nfunction imageUrls(listing: Listing): string[] {\n const images = arrayField(listing.images)\n .map((image) => (isRecord(image) && isRecord(image.rendition) ? stringField(image.rendition.src) : null))\n .filter((src): src is string => src !== null);\n\n if (images.length > 0) {\n return images.slice(0, 5);\n }\n\n const thumbnail = listing.thumbnail?.rendition?.src;\n return thumbnail ? [thumbnail] : [];\n}\n\nfunction arrayField(value: unknown): unknown[] {\n return Array.isArray(value) ? value : [];\n}\n\nfunction stringField(value: unknown): string | null {\n return typeof value === \"string\" && value.length > 0 ? value : null;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n","export interface ThrottleRetryOptions {\n minDelayMs?: number;\n retryDelaysMs?: readonly number[];\n now?: () => number;\n sleep?: (ms: number) => Promise<void>;\n}\n\nexport type ThrottledRetry = <T>(operation: () => Promise<T>) => Promise<T>;\n\nconst DEFAULT_MIN_DELAY_MS = 700;\nconst DEFAULT_RETRY_DELAYS_MS = [1000, 3000] as const;\n\nexport const runWithThrottleAndRetry = createThrottledRetry();\n\nexport function createThrottledRetry(options: ThrottleRetryOptions = {}): ThrottledRetry {\n const minDelayMs = options.minDelayMs ?? DEFAULT_MIN_DELAY_MS;\n const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;\n const now = options.now ?? Date.now;\n const sleep = options.sleep ?? defaultSleep;\n let previous = Promise.resolve();\n let lastStartedAt: number | null = null;\n\n return async function throttledRetry<T>(operation: () => Promise<T>): Promise<T> {\n const runAfterPrevious = previous.catch(() => undefined);\n let release: () => void = () => undefined;\n previous = runAfterPrevious.then(() => new Promise<void>((resolve) => {\n release = resolve;\n }));\n\n await runAfterPrevious;\n\n try {\n for (let attempt = 0; ; attempt += 1) {\n if (lastStartedAt !== null) {\n const waitMs = minDelayMs - (now() - lastStartedAt);\n if (waitMs > 0) {\n await sleep(waitMs);\n }\n }\n\n lastStartedAt = now();\n\n try {\n return await operation();\n } catch (error) {\n if (!shouldRetry(error) || attempt >= retryDelaysMs.length) {\n throw normalizeError(error);\n }\n\n await sleep(retryDelaysMs[attempt]);\n }\n }\n } finally {\n release();\n }\n };\n}\n\nexport function shouldRetry(error: unknown): boolean {\n const status = statusFromError(error);\n\n if (status === undefined) {\n return isNetworkError(error);\n }\n\n if (isCloudflareBlock(error)) {\n return false;\n }\n\n return status === 429 || status >= 500;\n}\n\nexport function normalizeError(error: unknown): Error {\n if (isCloudflareBlock(error)) {\n return new Error(\"tutti.ch is rate-limiting this client; wait a few minutes\");\n }\n\n if (error instanceof Error) {\n return error;\n }\n\n return new Error(String(error));\n}\n\nfunction statusFromError(error: unknown): number | undefined {\n return isRecord(error) && typeof error.status === \"number\" ? error.status : undefined;\n}\n\nfunction isNetworkError(error: unknown): boolean {\n return error instanceof Error && (error.name === \"TuttiNetworkError\" || error.name === \"TypeError\");\n}\n\nfunction isCloudflareBlock(error: unknown): boolean {\n if (!isRecord(error) || error.status !== 403 || typeof error.body !== \"string\") {\n return false;\n }\n\n return /<html|<!doctype html/i.test(error.body);\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n\nfunction defaultSleep(ms: number): Promise<void> {\n return new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n}\n"],"mappings":"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","names":["isRecord"]}
@@ -0,0 +1,57 @@
1
+ interface ListingSummary {
2
+ id: string;
3
+ title: string;
4
+ price: string | null;
5
+ location: string | null;
6
+ category: string | null;
7
+ timestamp: string | null;
8
+ url: string;
9
+ }
10
+ interface SearchResultPage {
11
+ totalCount: number;
12
+ resolvedLocation?: string;
13
+ listings: ListingSummary[];
14
+ nextCursor: string | null;
15
+ }
16
+ interface ListingDetail extends ListingSummary {
17
+ description: string | null;
18
+ seller: {
19
+ id: string;
20
+ alias: string | null;
21
+ } | null;
22
+ images: string[];
23
+ address: string | null;
24
+ source: string | null;
25
+ language: string | null;
26
+ }
27
+ interface CategoryNode {
28
+ id: string;
29
+ label: string;
30
+ children: CategoryNode[];
31
+ }
32
+ interface LocalitySummary {
33
+ id: string;
34
+ name: string;
35
+ type: string | null;
36
+ }
37
+ interface TuttiSearchParams {
38
+ query?: string;
39
+ categoryId?: string;
40
+ priceMin?: number;
41
+ priceMax?: number;
42
+ freeOnly?: boolean;
43
+ location?: string;
44
+ radiusKm?: number;
45
+ sort?: "timestamp";
46
+ direction?: "asc" | "desc";
47
+ cursor?: string;
48
+ limit?: number;
49
+ }
50
+ interface TuttiAdapter {
51
+ search(params: TuttiSearchParams): Promise<SearchResultPage>;
52
+ getListing(id: string): Promise<ListingDetail>;
53
+ getCategories(): Promise<CategoryNode[]>;
54
+ searchLocalities(text: string): Promise<LocalitySummary[]>;
55
+ }
56
+
57
+ export type { TuttiAdapter as T };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "tutti-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server and CLI for searching tutti.ch classifieds.",
5
+ "license": "MIT",
6
+ "author": "domwoe",
7
+ "homepage": "https://github.com/domwoe/tutti-mcp#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/domwoe/tutti-mcp.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/domwoe/tutti-mcp/issues"
14
+ },
15
+ "type": "module",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "bin": {
20
+ "tutti-mcp": "./dist/mcp.js",
21
+ "tutti": "./dist/cli.js"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "scripts": {
32
+ "build": "tsup",
33
+ "prepack": "npm run build",
34
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
35
+ "test": "vitest run",
36
+ "typecheck": "tsc --noEmit"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.29.0",
40
+ "tutti-api": "^2.1.1",
41
+ "zod": "^4.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^20.0.0",
45
+ "tsup": "^8.0.0",
46
+ "typescript": "^5.0.0",
47
+ "vitest": "^4.0.0"
48
+ }
49
+ }