@10ml/ai-sdk-perfume 0.0.0-stage → 1.0.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 +21 -0
- package/README.md +96 -2
- package/dist/index.d.ts +5745 -0
- package/dist/index.js +213 -0
- package/package.json +67 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @10ml/ai-sdk-perfume: Vercel AI SDK tools for the free 10ML Perfume Finder (the 10ML Fragrance Index,
|
|
3
|
+
* https://mcp.10ml.com/api/v1). Read-only, no API key.
|
|
4
|
+
*
|
|
5
|
+
* import { generateText } from 'ai';
|
|
6
|
+
* import { perfumeTools } from '@10ml/ai-sdk-perfume';
|
|
7
|
+
*
|
|
8
|
+
* const { text } = await generateText({ model, tools: perfumeTools(), prompt: 'An alternative to Baccarat Rouge 540?' });
|
|
9
|
+
*
|
|
10
|
+
* Every tool is also exported on its own as a factory, e.g. `searchPerfumes()`, in the shape of the AI SDK
|
|
11
|
+
* tools-as-package template: `tools: { searchPerfumes: searchPerfumes() }`.
|
|
12
|
+
*/
|
|
13
|
+
import { tool } from 'ai';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
export const DEFAULT_BASE_URL = 'https://mcp.10ml.com/api/v1';
|
|
16
|
+
/** Thrown for every non-2xx answer except 404 (a 404 body is returned: `found: false` plus suggestions). */
|
|
17
|
+
export class PerfumeApiError extends Error {
|
|
18
|
+
status;
|
|
19
|
+
code;
|
|
20
|
+
constructor(status, code, message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.name = 'PerfumeApiError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Builds the request URL: arrays join with commas, empty values are dropped. Exported for tests. */
|
|
28
|
+
export function buildUrl(baseUrl, path, query = {}) {
|
|
29
|
+
const params = new URLSearchParams();
|
|
30
|
+
for (const [key, value] of Object.entries(query)) {
|
|
31
|
+
if (value === undefined || value === null || value === '')
|
|
32
|
+
continue;
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
if (value.length === 0)
|
|
35
|
+
continue;
|
|
36
|
+
params.set(key, value.join(','));
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
params.set(key, String(value));
|
|
40
|
+
}
|
|
41
|
+
const qs = params.toString();
|
|
42
|
+
return `${baseUrl.replace(/\/+$/, '')}${path}${qs ? `?${qs}` : ''}`;
|
|
43
|
+
}
|
|
44
|
+
async function get(options, path, query) {
|
|
45
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
46
|
+
const timeoutMs = options.timeoutMs ?? 15000;
|
|
47
|
+
const controller = timeoutMs > 0 ? new AbortController() : undefined;
|
|
48
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
|
|
49
|
+
try {
|
|
50
|
+
const res = await doFetch(buildUrl(options.baseUrl ?? DEFAULT_BASE_URL, path, query), {
|
|
51
|
+
headers: { accept: 'application/json', ...options.headers },
|
|
52
|
+
signal: controller?.signal,
|
|
53
|
+
});
|
|
54
|
+
const text = await res.text();
|
|
55
|
+
let body;
|
|
56
|
+
try {
|
|
57
|
+
body = text ? JSON.parse(text) : undefined;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
body = text;
|
|
61
|
+
}
|
|
62
|
+
if (res.ok || res.status === 404)
|
|
63
|
+
return body;
|
|
64
|
+
const err = body?.error;
|
|
65
|
+
throw new PerfumeApiError(res.status, err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
if (timer)
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const seg = (id) => encodeURIComponent(id);
|
|
73
|
+
const locale = z.enum(['en', 'de', 'fr', 'es', 'it', 'ro', 'el', 'hu', 'cs', 'pl']).optional().describe('Language of the 10ml.com links and labels. Default en.');
|
|
74
|
+
const detail = z.enum(['concise', 'detailed']).optional().describe("'concise' (default) or 'detailed' (adds note pyramid, seasons, occasions, longevity).");
|
|
75
|
+
const cursor = z.string().max(64).optional().describe('next_cursor from the previous page, to read the next page.');
|
|
76
|
+
const gender = z.enum(['men', 'women', 'unisex']);
|
|
77
|
+
const list = (what) => z.array(z.string()).optional().describe(what);
|
|
78
|
+
export const searchPerfumesInput = z.object({
|
|
79
|
+
query: z.string().max(200).optional().describe("Free text: a perfume name, house, notes or mood, e.g. 'fresh citrus summer for men', 'Tom Ford oud'."),
|
|
80
|
+
house: z.string().max(80).optional().describe("Only perfumes from this house, e.g. 'Dior', 'Maison Francis Kurkdjian', 'YSL'."),
|
|
81
|
+
notes_include: list("Every listed note must be present, e.g. ['oud', 'sandalwood']."),
|
|
82
|
+
notes_exclude: list("None of these notes may be present, e.g. ['patchouli']."),
|
|
83
|
+
accords: list("Accords that must be present, e.g. ['woody', 'sweet']."),
|
|
84
|
+
family: z.string().max(40).optional().describe("Fragrance family, e.g. 'chypre', 'fougere', 'gourmand', 'amber', 'citrus'."),
|
|
85
|
+
gender: gender.optional().describe("Audience; 'men' and 'women' also include unisex perfumes."),
|
|
86
|
+
season: z.enum(['spring', 'summer', 'autumn', 'winter']).optional().describe('Season the perfume suits.'),
|
|
87
|
+
occasion: z.enum(['daytime', 'office', 'casual', 'evening', 'date night', 'special occasion']).optional().describe('Occasion the perfume suits.'),
|
|
88
|
+
style: z
|
|
89
|
+
.enum(['aquatic', 'boozy', 'clean', 'creamy', 'dark', 'floral', 'fresh', 'citrusy', 'fruity', 'gourmand', 'green', 'leathery', 'musky', 'powdery', 'sensual', 'smoky', 'spicy', 'sweet', 'woody'])
|
|
90
|
+
.optional()
|
|
91
|
+
.describe('A style word the scent should have.'),
|
|
92
|
+
iconic: z.boolean().optional().describe("true = only the editors' iconic, era-defining classics (combines with every other filter)."),
|
|
93
|
+
limit: z.number().int().min(1).max(20).optional().describe('Results per page, 1 to 20. Default 8.'),
|
|
94
|
+
cursor,
|
|
95
|
+
detail,
|
|
96
|
+
locale,
|
|
97
|
+
});
|
|
98
|
+
export const getPerfumeInput = z.object({
|
|
99
|
+
perfume: z.string().describe("A perfume by name, optionally with its house, e.g. 'Baccarat Rouge 540', 'Dior Sauvage', 'Delina'."),
|
|
100
|
+
house: z.string().max(80).optional().describe("House, when the name alone is ambiguous, e.g. 'Creed'."),
|
|
101
|
+
detail,
|
|
102
|
+
locale,
|
|
103
|
+
});
|
|
104
|
+
export const findSimilarPerfumesInput = z.object({
|
|
105
|
+
perfume: z.string().max(120).optional().describe("A perfume to start from, by name, e.g. 'Dior Sauvage'. Omit and pass notes or accords for a perfume outside the index."),
|
|
106
|
+
house: z.string().max(80).optional().describe('House of the perfume when the name alone is ambiguous.'),
|
|
107
|
+
notes: list("Known notes of the starting perfume, e.g. ['jasmine', 'saffron', 'amber']."),
|
|
108
|
+
accords: list("Known accords of the starting perfume, e.g. ['amber', 'woody', 'sweet']."),
|
|
109
|
+
steer: z.enum(['fresher', 'sweeter', 'darker', 'lighter', 'longer_lasting', 'more_projection']).optional().describe('Move away from the starting point in one direction.'),
|
|
110
|
+
gender: gender.optional().describe('Only return perfumes for this audience (men and women include unisex).'),
|
|
111
|
+
other_houses_only: z.boolean().optional().describe('true leaves out perfumes from the same house as the starting perfume.'),
|
|
112
|
+
limit: z.number().int().min(1).max(15).optional().describe('Results per page, 1 to 15. Default 8.'),
|
|
113
|
+
cursor,
|
|
114
|
+
detail,
|
|
115
|
+
locale,
|
|
116
|
+
});
|
|
117
|
+
export const findPerfumeAlternativesInput = z.object({
|
|
118
|
+
perfume: z.string().max(120).describe("The famous perfume to find alternatives for, e.g. 'Creed Aventus', 'Baccarat Rouge 540', 'Delina'."),
|
|
119
|
+
house: z.string().max(80).optional().describe("House of the original when the name is ambiguous, e.g. 'Parfums de Marly'."),
|
|
120
|
+
closeness: z.enum(['very close', 'similar vibe', 'same spirit']).optional().describe('Only alternatives at this closeness.'),
|
|
121
|
+
limit: z.number().int().min(1).max(10).optional().describe('Results per page, 1 to 10. Default 6.'),
|
|
122
|
+
cursor,
|
|
123
|
+
locale,
|
|
124
|
+
});
|
|
125
|
+
export const explainNoteInput = z.object({
|
|
126
|
+
note: z.string().describe("The note, e.g. 'ambroxan', 'oud', 'sandalwood', 'Bergamotte' (other languages are understood)."),
|
|
127
|
+
locale,
|
|
128
|
+
});
|
|
129
|
+
export const getHouseInput = z.object({
|
|
130
|
+
house: z.string().describe("The house, e.g. 'Creed', 'YSL', 'Maison Francis Kurkdjian', 'Parfums de Marly'."),
|
|
131
|
+
locale,
|
|
132
|
+
});
|
|
133
|
+
export const listHousesInput = z.object({
|
|
134
|
+
type: z.enum(['designer', 'niche', 'middle-eastern', 'indie', 'celebrity']).optional().describe('Only houses of this type.'),
|
|
135
|
+
country: z.string().max(40).optional().describe("Only houses from this country, e.g. 'France', 'Italy', 'United Arab Emirates'."),
|
|
136
|
+
query: z.string().max(60).optional().describe("Part of a house name, e.g. 'tom' or 'maison'."),
|
|
137
|
+
limit: z.number().int().min(1).max(50).optional().describe('Results per page, 1 to 50. Default 20.'),
|
|
138
|
+
cursor,
|
|
139
|
+
locale,
|
|
140
|
+
});
|
|
141
|
+
export const searchGuidesInput = z.object({
|
|
142
|
+
query: z.string().max(120).optional().describe("What the guide is about, e.g. 'how to make perfume last', 'oud', 'Creed Aventus alternatives', 'gift'."),
|
|
143
|
+
type: z.enum(['how-to', 'list', 'alternatives', 'note', 'house']).optional().describe('how-to, list (rankings and comparisons), alternatives, note (a note explained) or house (a house mapped).'),
|
|
144
|
+
limit: z.number().int().min(1).max(20).optional().describe('Results per page, 1 to 20. Default 8.'),
|
|
145
|
+
cursor,
|
|
146
|
+
locale,
|
|
147
|
+
});
|
|
148
|
+
/** Tool argument names that differ from the API's parameter names: `query` is `q`, `detail` is `format`. */
|
|
149
|
+
const toQuery = (args, rename = { query: 'q', detail: 'format' }) => {
|
|
150
|
+
const out = {};
|
|
151
|
+
for (const [k, v] of Object.entries(args))
|
|
152
|
+
out[rename[k] ?? k] = v;
|
|
153
|
+
return out;
|
|
154
|
+
};
|
|
155
|
+
/** Search perfumes by free text, house, notes, accords, family, audience, season, occasion or style. */
|
|
156
|
+
export const searchPerfumes = (options = {}) => tool({
|
|
157
|
+
description: "Find perfumes in the 10ML Fragrance Index (6,000+ perfumes). Use for 'a fresh citrus summer cologne for men', 'vanilla and oud perfumes without patchouli', 'Dior perfumes for the office' or 'iconic chypres'. Returns matching perfumes with house, family, accords and key notes. Not for the full profile of one named perfume (use getPerfume) or look-alikes (use findSimilarPerfumes).",
|
|
158
|
+
inputSchema: searchPerfumesInput,
|
|
159
|
+
execute: async (args) => get(options, '/perfumes', toQuery(args)),
|
|
160
|
+
});
|
|
161
|
+
/** The full profile of one perfume: note pyramid, accords, seasons and similar scents. */
|
|
162
|
+
export const getPerfume = (options = {}) => tool({
|
|
163
|
+
description: "Get the profile of one perfume by name: house, year, family, accords, the note pyramid (top, heart and base notes), seasons, occasions and similar perfumes. Use for 'what notes are in Delina?' or 'what does Baccarat Rouge 540 smell like?'. An unknown name returns found=false with spelling suggestions.",
|
|
164
|
+
inputSchema: getPerfumeInput,
|
|
165
|
+
execute: async ({ perfume, ...rest }) => get(options, `/perfumes/${seg(perfume)}`, toQuery(rest)),
|
|
166
|
+
});
|
|
167
|
+
/** Perfumes that smell like one you name, or like a list of notes and accords. */
|
|
168
|
+
export const findSimilarPerfumes = (options = {}) => tool({
|
|
169
|
+
description: "Find perfumes that smell like a perfume the user loves, ranked by shared notes, accords and style, with the reason for each. Use for 'what smells like Dior Sauvage but fresher?' (steer='fresher') or, for a perfume outside the index, pass its notes and accords. Not for the editors' alternatives to a famous perfume from other houses (use findPerfumeAlternatives).",
|
|
170
|
+
inputSchema: findSimilarPerfumesInput,
|
|
171
|
+
execute: async (args) => get(options, '/similar', toQuery(args)),
|
|
172
|
+
});
|
|
173
|
+
/** The editors' alternatives to a famous perfume, from other houses. */
|
|
174
|
+
export const findPerfumeAlternatives = (options = {}) => tool({
|
|
175
|
+
description: "Find alternatives to a famous perfume from other houses, as picked by 10ML's fragrance editors, each marked 'very close', 'similar vibe' or 'same spirit'. Use for 'an alternative to Creed Aventus' or 'something like Baccarat Rouge 540 from another house'.",
|
|
176
|
+
inputSchema: findPerfumeAlternativesInput,
|
|
177
|
+
execute: async (args) => get(options, '/alternatives', toQuery(args)),
|
|
178
|
+
});
|
|
179
|
+
/** What a fragrance note smells like, what it pairs with and which perfumes it leads. */
|
|
180
|
+
export const explainNote = (options = {}) => tool({
|
|
181
|
+
description: "Explain a fragrance note: what it smells like, its family and origin, what it pairs with and the perfumes it leads. Use for 'what does ambroxan smell like?' or 'what goes well with oud?'. Note names in other languages are understood.",
|
|
182
|
+
inputSchema: explainNoteInput,
|
|
183
|
+
execute: async ({ note, ...rest }) => get(options, `/notes/${seg(note)}`, toQuery(rest)),
|
|
184
|
+
});
|
|
185
|
+
/** A perfume house: country, style, what it is known for and its signature perfumes. */
|
|
186
|
+
export const getPerfumeHouse = (options = {}) => tool({
|
|
187
|
+
description: "Get one perfume house: country, type (designer, niche, middle-eastern), style, what it is known for and its signature perfumes. Use for 'tell me about Creed' or 'what is Maison Francis Kurkdjian known for?'.",
|
|
188
|
+
inputSchema: getHouseInput,
|
|
189
|
+
execute: async ({ house, ...rest }) => get(options, `/houses/${seg(house)}`, toQuery(rest)),
|
|
190
|
+
});
|
|
191
|
+
/** Browse perfume houses by type, country or name fragment. */
|
|
192
|
+
export const listPerfumeHouses = (options = {}) => tool({
|
|
193
|
+
description: "Browse perfume houses by type (designer, niche, middle-eastern, indie, celebrity), country or part of a name. Use for 'which niche houses are from Italy?'.",
|
|
194
|
+
inputSchema: listHousesInput,
|
|
195
|
+
execute: async (args) => get(options, '/houses', toQuery(args, {})),
|
|
196
|
+
});
|
|
197
|
+
/** 10ML perfume guides and articles to read or cite. */
|
|
198
|
+
export const searchPerfumeGuides = (options = {}) => tool({
|
|
199
|
+
description: "Find 10ML perfume guides and articles to read or cite, in ten languages: how-tos, rankings, alternatives guides, notes explained and house guides. Use for 'how do I make perfume last longer?' or 'a guide to oud perfumes'.",
|
|
200
|
+
inputSchema: searchGuidesInput,
|
|
201
|
+
execute: async (args) => get(options, '/guides', toQuery(args)),
|
|
202
|
+
});
|
|
203
|
+
/** All eight tools as a ready-made AI SDK tool set: `generateText({ model, tools: perfumeTools(), ... })`. */
|
|
204
|
+
export const perfumeTools = (options = {}) => ({
|
|
205
|
+
searchPerfumes: searchPerfumes(options),
|
|
206
|
+
getPerfume: getPerfume(options),
|
|
207
|
+
findSimilarPerfumes: findSimilarPerfumes(options),
|
|
208
|
+
findPerfumeAlternatives: findPerfumeAlternatives(options),
|
|
209
|
+
explainNote: explainNote(options),
|
|
210
|
+
getPerfumeHouse: getPerfumeHouse(options),
|
|
211
|
+
listPerfumeHouses: listPerfumeHouses(options),
|
|
212
|
+
searchPerfumeGuides: searchPerfumeGuides(options),
|
|
213
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@10ml/ai-sdk-perfume",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Vercel AI SDK tools for the free 10ML Perfume Finder: search perfumes, note pyramids, accords, similar scents, alternatives, notes and houses from the 10ML Fragrance Index. No API key.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai-sdk",
|
|
7
|
+
"vercel-ai-sdk",
|
|
8
|
+
"ai-sdk-tool",
|
|
9
|
+
"ai-sdk-tools",
|
|
10
|
+
"tools",
|
|
11
|
+
"tool-calling",
|
|
12
|
+
"perfume",
|
|
13
|
+
"fragrance",
|
|
14
|
+
"perfume-api",
|
|
15
|
+
"fragrance-api",
|
|
16
|
+
"perfume-mcp",
|
|
17
|
+
"mcp",
|
|
18
|
+
"perfume-recommendation",
|
|
19
|
+
"similar-perfumes",
|
|
20
|
+
"fragrance-notes",
|
|
21
|
+
"fragrantica-alternative"
|
|
22
|
+
],
|
|
23
|
+
"homepage": "https://10ml.com/perfume-mcp-server",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://gitlab.com/10ml/perfume-finder-mcp.git"
|
|
27
|
+
},
|
|
28
|
+
"author": "10ML Team <contact@10ml.com> (https://10ml.com)",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"type": "module",
|
|
31
|
+
"main": "dist/index.js",
|
|
32
|
+
"types": "dist/index.d.ts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"default": "./dist/index.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"sideEffects": false,
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsc",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"example": "tsx examples/generate-text.ts",
|
|
55
|
+
"prepublishOnly": "npm run build"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"ai": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
59
|
+
"zod": "^3.25.76 || ^4.0.0"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/node": "^22.20.5",
|
|
63
|
+
"ai": "^7.0.0",
|
|
64
|
+
"tsx": "^4.20.0",
|
|
65
|
+
"typescript": "^5.9.0",
|
|
66
|
+
"vitest": "^5.0.5",
|
|
67
|
+
"zod": "^4.0.0"
|
|
68
|
+
}
|
|
69
|
+
}
|