universalis-mcp-server 0.1.0 → 0.2.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,435 @@
1
+ import { z } from "zod";
2
+ import { BaseOutputSchema, ResponseFormatSchema } from "../schemas/common.js";
3
+ import { expandMateriaCategory } from "../services/materia.js";
4
+ import { buildToolResponse } from "../utils/format.js";
5
+ import { buildNameQueryFromTargets, dedupeNameTargets, ensureFieldsInclude, extractNamedResults, findBestResult, } from "../utils/xivapi.js";
6
+ import { chunkArray } from "../utils/array.js";
7
+ const LanguageSchema = z
8
+ .enum(["none", "ja", "en", "de", "fr", "chs", "cht", "kr"])
9
+ .optional()
10
+ .describe("XIVAPI language code.");
11
+ const MatchModeSchema = z
12
+ .enum(["partial", "exact"])
13
+ .default("exact")
14
+ .describe("Match mode for item name queries.");
15
+ const PriceMetricSchema = z
16
+ .enum(["min_listing", "average_sale_price"])
17
+ .default("min_listing")
18
+ .describe("Price metric to use for profitability.");
19
+ const PriceVariantSchema = z
20
+ .enum(["nq", "hq", "best"])
21
+ .default("best")
22
+ .describe("Which price variant to consider when ranking.");
23
+ const CostItemSchema = z
24
+ .object({
25
+ name: z.string().min(1).describe("Item name."),
26
+ cost: z.number().positive().describe("Cost to acquire the item (numeric)."),
27
+ cost_unit: z.string().optional().describe('Cost unit label. Example: "Bicolor Gemstone".'),
28
+ })
29
+ .strict();
30
+ async function buildCostTargets(items, matchMode, clients) {
31
+ const targets = [];
32
+ const expandedItems = [];
33
+ const missingGradeItems = [];
34
+ for (const item of items) {
35
+ const costUnit = item.cost_unit ?? "Bicolor Gemstone";
36
+ const expansion = await expandMateriaCategory(item.name, clients.xivapi);
37
+ if (!expansion) {
38
+ targets.push({
39
+ name: item.name,
40
+ sourceInput: item.name,
41
+ cost: item.cost,
42
+ cost_unit: costUnit,
43
+ matchMode,
44
+ origin: "direct",
45
+ });
46
+ continue;
47
+ }
48
+ expandedItems.push({
49
+ input_name: item.name,
50
+ category: expansion.category,
51
+ grade: expansion.grade,
52
+ expanded_names: expansion.expandedNames,
53
+ });
54
+ if (!expansion.grade) {
55
+ missingGradeItems.push({
56
+ input_name: item.name,
57
+ cost: item.cost,
58
+ cost_unit: costUnit,
59
+ });
60
+ continue;
61
+ }
62
+ for (const expandedName of expansion.expandedNames) {
63
+ targets.push({
64
+ name: expandedName,
65
+ sourceInput: item.name,
66
+ cost: item.cost,
67
+ cost_unit: costUnit,
68
+ matchMode: "exact",
69
+ origin: "expanded",
70
+ });
71
+ }
72
+ }
73
+ return { targets, expandedItems, missingGradeItems };
74
+ }
75
+ function buildQueryTargets(targets) {
76
+ return dedupeNameTargets(targets.map((target) => ({ name: target.name, matchMode: target.matchMode })));
77
+ }
78
+ const AggregatedScopeOrder = ["world", "dc", "region"];
79
+ function pickScopedMetric(node, key) {
80
+ if (!node || typeof node !== "object")
81
+ return { value: null, scope: null };
82
+ const record = node;
83
+ for (const scope of AggregatedScopeOrder) {
84
+ const entry = record[scope];
85
+ const value = entry ? entry[key] : undefined;
86
+ if (typeof value === "number") {
87
+ return { value, scope };
88
+ }
89
+ }
90
+ return { value: null, scope: null };
91
+ }
92
+ function scoreItem(price, velocity, cost) {
93
+ if (!price || cost <= 0)
94
+ return { gilPerCost: null, score: null };
95
+ const gilPerCost = price / cost;
96
+ if (!velocity) {
97
+ return { gilPerCost, score: gilPerCost };
98
+ }
99
+ return { gilPerCost, score: gilPerCost * velocity };
100
+ }
101
+ export function registerWorkflowTools(server, clients) {
102
+ server.registerTool("universalis_rank_items_by_profitability", {
103
+ title: "Rank Items by Profitability",
104
+ description: "Resolve item names with XIVAPI, fetch aggregated market data, and rank items by demand, profit, and supply context.",
105
+ inputSchema: z
106
+ .object({
107
+ world_dc_region: z
108
+ .string()
109
+ .min(1)
110
+ .describe('World, data center, or region. Example: "Moogle".'),
111
+ items: z.array(CostItemSchema).min(1).max(300),
112
+ match_mode: MatchModeSchema,
113
+ price_metric: PriceMetricSchema,
114
+ price_variant: PriceVariantSchema,
115
+ marketable_only: z
116
+ .boolean()
117
+ .default(true)
118
+ .describe("Filter out unmarketable items using Universalis marketable list."),
119
+ min_velocity: z
120
+ .number()
121
+ .nonnegative()
122
+ .optional()
123
+ .describe("Minimum daily sale velocity required to score an item."),
124
+ include_supply: z
125
+ .boolean()
126
+ .default(true)
127
+ .describe("Include supply metrics (units for sale, listings count)."),
128
+ language: LanguageSchema,
129
+ response_format: ResponseFormatSchema,
130
+ })
131
+ .strict(),
132
+ outputSchema: BaseOutputSchema,
133
+ annotations: {
134
+ readOnlyHint: true,
135
+ destructiveHint: false,
136
+ idempotentHint: true,
137
+ openWorldHint: true,
138
+ },
139
+ }, async ({ world_dc_region, items, match_mode, price_metric, price_variant, marketable_only, min_velocity, include_supply, language, response_format, }) => {
140
+ const { targets, expandedItems, missingGradeItems } = await buildCostTargets(items, match_mode, clients);
141
+ const queryTargets = buildQueryTargets(targets);
142
+ const queryClause = queryTargets.length ? buildNameQueryFromTargets(queryTargets) : "";
143
+ const searchFields = ensureFieldsInclude(undefined, ["Name"]);
144
+ const searchData = queryTargets.length
145
+ ? await clients.xivapi.search({
146
+ query: queryClause,
147
+ sheets: "Item",
148
+ limit: Math.min(queryTargets.length * 5, 500),
149
+ language,
150
+ fields: searchFields,
151
+ })
152
+ : { results: [] };
153
+ const results = Array.isArray(searchData.results)
154
+ ? (searchData.results ?? [])
155
+ : [];
156
+ const namedResults = extractNamedResults(results);
157
+ const resolved = targets.map((target) => {
158
+ const best = findBestResult(namedResults, target.name, target.matchMode);
159
+ const fieldsObj = best?.fields;
160
+ return {
161
+ input_name: target.sourceInput,
162
+ expanded_name: target.origin === "expanded" ? target.name : null,
163
+ resolved_name: typeof fieldsObj?.Name === "string" ? fieldsObj.Name : null,
164
+ item_id: typeof best?.row_id === "number" ? best.row_id : null,
165
+ score: typeof best?.score === "number" ? best.score : null,
166
+ match_type: best ? (target.origin === "expanded" ? "expanded" : target.matchMode) : "none",
167
+ match_mode: target.matchMode,
168
+ resolution_source: target.origin,
169
+ cost: target.cost,
170
+ cost_unit: target.cost_unit,
171
+ };
172
+ });
173
+ const unresolvedDirect = targets
174
+ .map((target, index) => ({ target, index }))
175
+ .filter(({ target, index }) => target.origin === "direct" && target.matchMode === "exact" && resolved[index]?.item_id == null);
176
+ let fallbackResults = [];
177
+ let fallbackQueryClause = null;
178
+ if (unresolvedDirect.length > 0) {
179
+ const fallbackTargets = dedupeNameTargets(unresolvedDirect.map(({ target }) => ({
180
+ name: target.name,
181
+ matchMode: "partial",
182
+ })));
183
+ fallbackQueryClause = buildNameQueryFromTargets(fallbackTargets);
184
+ const fallbackData = await clients.xivapi.search({
185
+ query: fallbackQueryClause,
186
+ sheets: "Item",
187
+ limit: Math.min(fallbackTargets.length * 5, 500),
188
+ language,
189
+ fields: searchFields,
190
+ });
191
+ fallbackResults = Array.isArray(fallbackData.results)
192
+ ? (fallbackData.results ?? [])
193
+ : [];
194
+ const fallbackNamedResults = extractNamedResults(fallbackResults);
195
+ for (const { target, index } of unresolvedDirect) {
196
+ const best = findBestResult(fallbackNamedResults, target.name, "partial");
197
+ if (!best)
198
+ continue;
199
+ const fieldsObj = best.fields;
200
+ resolved[index] = {
201
+ input_name: target.sourceInput,
202
+ expanded_name: null,
203
+ resolved_name: typeof fieldsObj?.Name === "string" ? fieldsObj.Name : null,
204
+ item_id: typeof best.row_id === "number" ? best.row_id : null,
205
+ score: typeof best.score === "number" ? best.score : null,
206
+ match_type: "fallback_partial",
207
+ match_mode: target.matchMode,
208
+ resolution_source: target.origin,
209
+ cost: target.cost,
210
+ cost_unit: target.cost_unit,
211
+ };
212
+ }
213
+ }
214
+ const missingGradeResolved = missingGradeItems.map((item) => ({
215
+ input_name: item.input_name,
216
+ expanded_name: null,
217
+ resolved_name: null,
218
+ item_id: null,
219
+ score: null,
220
+ match_type: "missing_grade",
221
+ match_mode: match_mode,
222
+ resolution_source: "category",
223
+ cost: item.cost,
224
+ cost_unit: item.cost_unit,
225
+ notes: ["Materia category missing grade. Specify I-XII to expand."],
226
+ }));
227
+ const allResolved = [...resolved, ...missingGradeResolved];
228
+ const marketableIds = marketable_only && resolved.some((entry) => entry.item_id != null)
229
+ ? new Set(await clients.universalis.listMarketableItems())
230
+ : null;
231
+ const resolvedIdSet = new Set();
232
+ const marketableFilteredIds = [];
233
+ for (const entry of resolved) {
234
+ if (entry.item_id == null)
235
+ continue;
236
+ if (marketableIds && !marketableIds.has(entry.item_id)) {
237
+ entry.marketable = false;
238
+ entry.notes = [...(entry.notes ?? []), "Item is not marketable on Universalis."];
239
+ marketableFilteredIds.push(entry.item_id);
240
+ continue;
241
+ }
242
+ if (marketableIds)
243
+ entry.marketable = true;
244
+ resolvedIdSet.add(entry.item_id);
245
+ }
246
+ const resolvedIds = Array.from(resolvedIdSet);
247
+ const aggregatedResults = [];
248
+ const failedItems = [];
249
+ for (const chunk of chunkArray(resolvedIds, 100)) {
250
+ const aggregated = await clients.universalis.getAggregatedMarketData(world_dc_region, chunk);
251
+ const chunkResults = Array.isArray(aggregated.results)
252
+ ? (aggregated.results ?? [])
253
+ : [];
254
+ const chunkFailed = Array.isArray(aggregated.failedItems)
255
+ ? (aggregated.failedItems ?? [])
256
+ : [];
257
+ aggregatedResults.push(...chunkResults);
258
+ failedItems.push(...chunkFailed);
259
+ }
260
+ const aggregatedMap = new Map();
261
+ for (const entry of aggregatedResults) {
262
+ if (typeof entry.itemId === "number") {
263
+ aggregatedMap.set(entry.itemId, entry);
264
+ }
265
+ }
266
+ const supplyMap = new Map();
267
+ if (include_supply && resolvedIds.length > 0) {
268
+ for (const chunk of chunkArray(resolvedIds, 100)) {
269
+ const fields = chunk.length === 1
270
+ ? "itemID,unitsForSale,listingsCount,worldName,dcName,regionName"
271
+ : "items.unitsForSale,items.listingsCount,worldName,dcName,regionName";
272
+ const currentData = await clients.universalis.getCurrentMarketData(world_dc_region, chunk, {
273
+ fields,
274
+ });
275
+ const current = currentData;
276
+ const scope = typeof current.worldName === "string"
277
+ ? "world"
278
+ : typeof current.dcName === "string"
279
+ ? "dc"
280
+ : typeof current.regionName === "string"
281
+ ? "region"
282
+ : null;
283
+ const itemsMap = current.items;
284
+ if (itemsMap) {
285
+ for (const [key, value] of Object.entries(itemsMap)) {
286
+ const itemId = Number(key);
287
+ if (Number.isNaN(itemId))
288
+ continue;
289
+ const listingsCount = typeof value?.listingsCount === "number" ? value.listingsCount : null;
290
+ const unitsForSale = typeof value?.unitsForSale === "number" ? value.unitsForSale : null;
291
+ supplyMap.set(itemId, { unitsForSale, listingsCount, scope });
292
+ }
293
+ continue;
294
+ }
295
+ const itemId = typeof current.itemID === "number" ? current.itemID : null;
296
+ if (itemId != null) {
297
+ const listingsCount = typeof current.listingsCount === "number" ? current.listingsCount : null;
298
+ const unitsForSale = typeof current.unitsForSale === "number" ? current.unitsForSale : null;
299
+ supplyMap.set(itemId, { unitsForSale, listingsCount, scope });
300
+ }
301
+ }
302
+ }
303
+ const resolvedInputs = new Set(allResolved.filter((entry) => entry.item_id != null).map((entry) => entry.input_name));
304
+ const unmatched = items.map((item) => item.name).filter((name) => !resolvedInputs.has(name));
305
+ const unmatchedExpanded = allResolved
306
+ .filter((entry) => entry.resolution_source === "expanded" && entry.item_id == null)
307
+ .map((entry) => entry.expanded_name ?? entry.input_name);
308
+ const ranked = allResolved.map((entry) => {
309
+ const supply = entry.item_id ? supplyMap.get(entry.item_id) : null;
310
+ if (!entry.item_id) {
311
+ const notes = Array.isArray(entry.notes) ? [...entry.notes] : [];
312
+ if (notes.length === 0)
313
+ notes.push("Unresolved item name.");
314
+ return {
315
+ ...entry,
316
+ price: null,
317
+ price_scope: null,
318
+ demand_per_day: null,
319
+ demand_scope: null,
320
+ supply_units: null,
321
+ listings_count: null,
322
+ supply_scope: null,
323
+ saturation_ratio: null,
324
+ gil_per_cost: null,
325
+ ranking_score: null,
326
+ notes,
327
+ };
328
+ }
329
+ if (entry.marketable === false) {
330
+ const notes = Array.isArray(entry.notes) ? [...entry.notes] : [];
331
+ return {
332
+ ...entry,
333
+ price: null,
334
+ price_scope: null,
335
+ demand_per_day: null,
336
+ demand_scope: null,
337
+ supply_units: supply?.unitsForSale ?? null,
338
+ listings_count: supply?.listingsCount ?? null,
339
+ supply_scope: supply?.scope ?? null,
340
+ saturation_ratio: null,
341
+ gil_per_cost: null,
342
+ ranking_score: null,
343
+ notes,
344
+ };
345
+ }
346
+ const aggregatedEntry = aggregatedMap.get(entry.item_id);
347
+ const metricKey = price_metric === "min_listing" ? "minListing" : "averageSalePrice";
348
+ const evaluateVariant = (variant) => {
349
+ const variantData = aggregatedEntry?.[variant];
350
+ const priceMetric = pickScopedMetric(variantData?.[metricKey], "price");
351
+ const velocityMetric = pickScopedMetric(variantData?.dailySaleVelocity, "quantity");
352
+ const scored = scoreItem(priceMetric.value, velocityMetric.value, entry.cost);
353
+ return {
354
+ variant,
355
+ price: priceMetric.value,
356
+ price_scope: priceMetric.scope,
357
+ demand_per_day: velocityMetric.value,
358
+ demand_scope: velocityMetric.scope,
359
+ gil_per_cost: scored.gilPerCost,
360
+ ranking_score: scored.score,
361
+ };
362
+ };
363
+ const candidates = price_variant === "best"
364
+ ? [evaluateVariant("nq"), evaluateVariant("hq")]
365
+ : [evaluateVariant(price_variant)];
366
+ const best = candidates.sort((a, b) => (b.ranking_score ?? -1) - (a.ranking_score ?? -1))[0];
367
+ const notes = [];
368
+ if (best.price == null)
369
+ notes.push("No price data available.");
370
+ if (best.demand_per_day == null)
371
+ notes.push("No demand data available.");
372
+ if (min_velocity != null) {
373
+ if (best.demand_per_day == null) {
374
+ notes.push(`Minimum demand threshold (${min_velocity}) not applied.`);
375
+ }
376
+ else if (best.demand_per_day < min_velocity) {
377
+ notes.push(`Below minimum demand threshold (${min_velocity}).`);
378
+ }
379
+ }
380
+ return {
381
+ ...entry,
382
+ price_variant: best.variant,
383
+ price_metric,
384
+ price: best.price,
385
+ price_scope: best.price_scope,
386
+ demand_per_day: best.demand_per_day,
387
+ demand_scope: best.demand_scope,
388
+ supply_units: supply?.unitsForSale ?? null,
389
+ listings_count: supply?.listingsCount ?? null,
390
+ supply_scope: supply?.scope ?? null,
391
+ saturation_ratio: supply?.unitsForSale != null && best.demand_per_day != null && best.demand_per_day > 0
392
+ ? supply.unitsForSale / best.demand_per_day
393
+ : null,
394
+ gil_per_cost: best.gil_per_cost,
395
+ ranking_score: min_velocity != null && best.demand_per_day != null && best.demand_per_day < min_velocity
396
+ ? null
397
+ : best.ranking_score,
398
+ notes: notes.length ? notes : undefined,
399
+ };
400
+ });
401
+ ranked.sort((a, b) => (b.ranking_score ?? -1) - (a.ranking_score ?? -1));
402
+ const top = ranked.filter((entry) => entry.ranking_score != null).slice(0, 3);
403
+ const summaryLines = top.map((entry, index) => {
404
+ const name = entry.resolved_name ?? entry.expanded_name ?? entry.input_name;
405
+ const score = entry.ranking_score != null ? entry.ranking_score.toFixed(2) : "n/a";
406
+ return `${index + 1}. ${name} (${score} score)`;
407
+ });
408
+ return buildToolResponse({
409
+ title: "Profitability Ranking",
410
+ responseFormat: response_format,
411
+ data: {
412
+ ranking: ranked,
413
+ unmatched,
414
+ unmatched_expanded: unmatchedExpanded,
415
+ expanded_items: expandedItems,
416
+ missing_expansions: missingGradeItems.map((item) => item.input_name),
417
+ unmarketable_item_ids: marketableFilteredIds,
418
+ failed_items: failedItems,
419
+ },
420
+ meta: {
421
+ source: "universalis",
422
+ endpoint: "/aggregated/{worldDcRegion}/{itemIds}",
423
+ world_dc_region,
424
+ price_metric,
425
+ price_variant,
426
+ marketable_only,
427
+ min_velocity,
428
+ include_supply,
429
+ query: queryClause,
430
+ ...(fallbackQueryClause ? { fallback_query: fallbackQueryClause } : {}),
431
+ },
432
+ summaryLines,
433
+ });
434
+ });
435
+ }
@@ -0,0 +1,9 @@
1
+ export function chunkArray(items, chunkSize) {
2
+ if (chunkSize <= 0)
3
+ return [items];
4
+ const chunks = [];
5
+ for (let i = 0; i < items.length; i += chunkSize) {
6
+ chunks.push(items.slice(i, i + chunkSize));
7
+ }
8
+ return chunks;
9
+ }
@@ -0,0 +1,71 @@
1
+ import { normalizeName } from "./xivapi.js";
2
+ const ROMAN_PATTERN = /^[ivxlcdm]+$/i;
3
+ const ROMAN_TABLE = [
4
+ [1000, "M"],
5
+ [900, "CM"],
6
+ [500, "D"],
7
+ [400, "CD"],
8
+ [100, "C"],
9
+ [90, "XC"],
10
+ [50, "L"],
11
+ [40, "XL"],
12
+ [10, "X"],
13
+ [9, "IX"],
14
+ [5, "V"],
15
+ [4, "IV"],
16
+ [1, "I"],
17
+ ];
18
+ function toRoman(value) {
19
+ if (value <= 0 || value >= 4000)
20
+ return null;
21
+ let remaining = value;
22
+ let result = "";
23
+ for (const [amount, numeral] of ROMAN_TABLE) {
24
+ while (remaining >= amount) {
25
+ result += numeral;
26
+ remaining -= amount;
27
+ }
28
+ }
29
+ return result || null;
30
+ }
31
+ export function normalizeGradeToken(token) {
32
+ const trimmed = token.trim();
33
+ if (!trimmed)
34
+ return null;
35
+ if (ROMAN_PATTERN.test(trimmed)) {
36
+ return trimmed.toUpperCase();
37
+ }
38
+ const num = Number(trimmed);
39
+ if (!Number.isNaN(num)) {
40
+ return toRoman(num);
41
+ }
42
+ return null;
43
+ }
44
+ export function extractMateriaGrade(input) {
45
+ const match = input.match(/\bmateria\s+([ivxlcdm\d]+)\b/i);
46
+ if (!match)
47
+ return null;
48
+ return normalizeGradeToken(match[1]);
49
+ }
50
+ export function detectMateriaCategory(input) {
51
+ const normalized = normalizeName(input);
52
+ if (/^combat\s+materia(\s+[ivxlcdm\d]+)?$/.test(normalized)) {
53
+ return "combat";
54
+ }
55
+ if (/^(crafting|craftsman'?s)\s+materia(\s+[ivxlcdm\d]+)?$/.test(normalized)) {
56
+ return "crafting";
57
+ }
58
+ if (/^(gathering|gatherer'?s)\s+materia(\s+[ivxlcdm\d]+)?$/.test(normalized)) {
59
+ return "gathering";
60
+ }
61
+ return null;
62
+ }
63
+ export function parseMateriaCategoryInput(input) {
64
+ const category = detectMateriaCategory(input);
65
+ if (!category)
66
+ return null;
67
+ return {
68
+ category,
69
+ grade: extractMateriaGrade(input),
70
+ };
71
+ }
@@ -0,0 +1,71 @@
1
+ export function escapeQueryValue(value) {
2
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
3
+ }
4
+ export function buildNameClause(name, matchMode) {
5
+ const escaped = escapeQueryValue(name);
6
+ return matchMode === "exact" ? `Name="${escaped}"` : `Name~"${escaped}"`;
7
+ }
8
+ export function buildNameQueryFromTargets(targets) {
9
+ const clauses = targets.map((target) => buildNameClause(target.name, target.matchMode));
10
+ return clauses.join(" ");
11
+ }
12
+ export function buildNameQuery(names, matchMode) {
13
+ return buildNameQueryFromTargets(names.map((name) => ({ name, matchMode })));
14
+ }
15
+ export function dedupeNameTargets(targets) {
16
+ const targetMap = new Map();
17
+ for (const target of targets) {
18
+ const key = normalizeName(target.name);
19
+ const existing = targetMap.get(key);
20
+ if (!existing) {
21
+ targetMap.set(key, target);
22
+ continue;
23
+ }
24
+ if (existing.matchMode === "partial" && target.matchMode === "exact") {
25
+ targetMap.set(key, target);
26
+ }
27
+ }
28
+ return Array.from(targetMap.values());
29
+ }
30
+ export function normalizeName(value) {
31
+ return value.trim().toLowerCase();
32
+ }
33
+ export function ensureFieldsInclude(fields, required) {
34
+ if (!fields)
35
+ return required.join(",");
36
+ const parts = fields.split(",").map((part) => part.trim()).filter(Boolean);
37
+ for (const req of required) {
38
+ if (!parts.includes(req))
39
+ parts.push(req);
40
+ }
41
+ return parts.join(",");
42
+ }
43
+ export function extractNamedResults(results) {
44
+ return results
45
+ .map((result) => {
46
+ const fieldsObj = result.fields;
47
+ const name = typeof fieldsObj?.Name === "string" ? fieldsObj.Name : undefined;
48
+ if (!name)
49
+ return null;
50
+ return {
51
+ name,
52
+ normalizedName: normalizeName(name),
53
+ result,
54
+ };
55
+ })
56
+ .filter((entry) => Boolean(entry));
57
+ }
58
+ export function findBestResult(namedResults, targetName, matchMode) {
59
+ const key = normalizeName(targetName);
60
+ const candidates = namedResults
61
+ .filter((entry) => matchMode === "exact" ? entry.normalizedName === key : entry.normalizedName.includes(key))
62
+ .map((entry) => entry.result);
63
+ if (candidates.length === 0) {
64
+ return null;
65
+ }
66
+ return candidates.sort((a, b) => {
67
+ const scoreA = typeof a.score === "number" ? a.score : 0;
68
+ const scoreB = typeof b.score === "number" ? b.score : 0;
69
+ return scoreB - scoreA;
70
+ })[0];
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universalis-mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for Universalis and XIVAPI",
5
5
  "author": "Jakub Mucha <jakub.mucha@icloud.com>",
6
6
  "repository": {
@@ -17,7 +17,8 @@
17
17
  "universalis-mcp-server": "./dist/index.js"
18
18
  },
19
19
  "files": [
20
- "dist"
20
+ "dist",
21
+ "data"
21
22
  ],
22
23
  "keywords": [
23
24
  "mcp",
@@ -38,6 +39,7 @@
38
39
  },
39
40
  "scripts": {
40
41
  "build": "tsc && chmod 755 ./dist/index.js",
42
+ "update-materia": "node scripts/update-materia-data.js",
41
43
  "lint": "biome check .",
42
44
  "format": "biome format --write ."
43
45
  }