universalis-mcp-server 0.1.0 → 0.1.1

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.
@@ -1,6 +1,7 @@
1
1
  import { LRUCache } from "lru-cache";
2
2
  import { DEFAULT_XIVAPI_LANGUAGE, DEFAULT_XIVAPI_VERSION, XIVAPI_BASE_URL } from "../constants.js";
3
3
  import { requestJson } from "./http.js";
4
+ import { chunkArray } from "../utils/array.js";
4
5
  export class XivapiClient {
5
6
  baseUrl;
6
7
  timeoutMs;
@@ -52,6 +53,55 @@ export class XivapiClient {
52
53
  this.itemCache.set(cacheKey, data);
53
54
  return data;
54
55
  }
56
+ async getItemsByIds(itemIds, params = {}, chunkSize = 100) {
57
+ const uniqueIds = Array.from(new Set(itemIds));
58
+ if (uniqueIds.length === 0) {
59
+ return { rows: [] };
60
+ }
61
+ const chunks = chunkArray(uniqueIds, chunkSize);
62
+ const rows = [];
63
+ let schema;
64
+ let version;
65
+ for (const chunk of chunks) {
66
+ const normalized = this.withDefaults({ ...params });
67
+ const data = await requestJson({
68
+ baseUrl: this.baseUrl,
69
+ path: "/sheet/Item",
70
+ query: {
71
+ ...normalized,
72
+ rows: chunk.join(","),
73
+ },
74
+ limiter: this.limiter,
75
+ timeoutMs: this.timeoutMs,
76
+ userAgent: this.userAgent,
77
+ });
78
+ if (schema === undefined && "schema" in data) {
79
+ schema = data.schema;
80
+ }
81
+ if (version === undefined && "version" in data) {
82
+ version = data.version;
83
+ }
84
+ if (Array.isArray(data.rows)) {
85
+ rows.push(...data.rows);
86
+ }
87
+ }
88
+ return {
89
+ rows,
90
+ ...(schema !== undefined ? { schema } : {}),
91
+ ...(version !== undefined ? { version } : {}),
92
+ };
93
+ }
94
+ async getSheetRows(sheet, params = {}) {
95
+ const normalized = this.withDefaults({ ...params });
96
+ return requestJson({
97
+ baseUrl: this.baseUrl,
98
+ path: `/sheet/${sheet}`,
99
+ query: normalized,
100
+ limiter: this.limiter,
101
+ timeoutMs: this.timeoutMs,
102
+ userAgent: this.userAgent,
103
+ });
104
+ }
55
105
  withDefaults(params) {
56
106
  return {
57
107
  ...params,
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { BaseOutputSchema, ResponseFormatSchema } from "../schemas/common.js";
3
3
  import { buildToolResponse } from "../utils/format.js";
4
+ import { expandMateriaCategory } from "../services/materia.js";
5
+ import { buildNameQueryFromTargets, dedupeNameTargets, ensureFieldsInclude, extractNamedResults, findBestResult, } from "../utils/xivapi.js";
4
6
  const LanguageSchema = z
5
7
  .enum(["none", "ja", "en", "de", "fr", "chs", "cht", "kr"])
6
8
  .optional()
@@ -10,8 +12,49 @@ const MatchModeSchema = z
10
12
  .default("partial")
11
13
  .describe("Match mode for item name queries.");
12
14
  const DefaultItemFields = "Name,Icon,ItemSearchCategory,LevelItem";
13
- function escapeQueryValue(value) {
14
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
15
+ const NamesSchema = z
16
+ .array(z.string().min(1))
17
+ .min(1)
18
+ .max(100)
19
+ .describe("Item names to resolve (max 100).");
20
+ async function buildSearchTargets(names, matchMode, clients) {
21
+ const targets = [];
22
+ const expandedItems = [];
23
+ const missingGradeInputs = [];
24
+ for (const name of names) {
25
+ const expansion = await expandMateriaCategory(name, clients.xivapi);
26
+ if (!expansion) {
27
+ targets.push({
28
+ name,
29
+ sourceInput: name,
30
+ matchMode,
31
+ origin: "direct",
32
+ });
33
+ continue;
34
+ }
35
+ expandedItems.push({
36
+ input_name: name,
37
+ category: expansion.category,
38
+ grade: expansion.grade,
39
+ expanded_names: expansion.expandedNames,
40
+ });
41
+ if (!expansion.grade) {
42
+ missingGradeInputs.push(name);
43
+ continue;
44
+ }
45
+ for (const expandedName of expansion.expandedNames) {
46
+ targets.push({
47
+ name: expandedName,
48
+ sourceInput: name,
49
+ matchMode: "exact",
50
+ origin: "expanded",
51
+ });
52
+ }
53
+ }
54
+ return { targets, expandedItems, missingGradeInputs };
55
+ }
56
+ function buildQueryTargets(targets) {
57
+ return dedupeNameTargets(targets.map((target) => ({ name: target.name, matchMode: target.matchMode })));
15
58
  }
16
59
  export function registerLookupTools(server, clients) {
17
60
  server.registerTool("universalis_resolve_items_by_name", {
@@ -47,8 +90,12 @@ export function registerLookupTools(server, clients) {
47
90
  openWorldHint: true,
48
91
  },
49
92
  }, async ({ query, match_mode, limit, language, fields, response_format }) => {
50
- const escaped = escapeQueryValue(query);
51
- const queryClause = match_mode === "exact" ? `Name="${escaped}"` : `Name~"${escaped}"`;
93
+ const expansion = await expandMateriaCategory(query, clients.xivapi);
94
+ const expandedNames = expansion?.grade ? expansion.expandedNames : null;
95
+ const queryTargets = expandedNames
96
+ ? expandedNames.map((name) => ({ name, matchMode: "exact" }))
97
+ : [{ name: query, matchMode: match_mode }];
98
+ const queryClause = buildNameQueryFromTargets(queryTargets);
52
99
  const data = await clients.xivapi.search({
53
100
  query: queryClause,
54
101
  sheets: "Item",
@@ -65,6 +112,159 @@ export function registerLookupTools(server, clients) {
65
112
  endpoint: "/search",
66
113
  query: queryClause,
67
114
  limit,
115
+ ...(expansion
116
+ ? {
117
+ expanded_items: {
118
+ input_name: query,
119
+ category: expansion.category,
120
+ grade: expansion.grade,
121
+ expanded_names: expansion.expandedNames,
122
+ },
123
+ }
124
+ : {}),
125
+ ...(expansion && !expansion.grade
126
+ ? { notes: ["Materia category missing grade. Specify I-XII to expand."] }
127
+ : {}),
128
+ ...(language ? { language } : {}),
129
+ },
130
+ });
131
+ });
132
+ server.registerTool("universalis_resolve_items_by_names", {
133
+ title: "Resolve Items by Names (XIVAPI)",
134
+ description: "Resolve multiple item names in a single XIVAPI search query, returning best matches per name.",
135
+ inputSchema: z
136
+ .object({
137
+ names: NamesSchema,
138
+ match_mode: MatchModeSchema.default("exact"),
139
+ limit: z
140
+ .number()
141
+ .int()
142
+ .min(1)
143
+ .max(500)
144
+ .optional()
145
+ .describe("Maximum results to return (default: names.length * 5, max: 500)."),
146
+ language: LanguageSchema,
147
+ fields: z
148
+ .string()
149
+ .optional()
150
+ .describe("Comma-separated XIVAPI fields. Name is always included."),
151
+ response_format: ResponseFormatSchema,
152
+ })
153
+ .strict(),
154
+ outputSchema: BaseOutputSchema,
155
+ annotations: {
156
+ readOnlyHint: true,
157
+ destructiveHint: false,
158
+ idempotentHint: true,
159
+ openWorldHint: true,
160
+ },
161
+ }, async ({ names, match_mode, limit, language, fields, response_format }) => {
162
+ const { targets, expandedItems, missingGradeInputs } = await buildSearchTargets(names, match_mode, clients);
163
+ const queryTargets = buildQueryTargets(targets);
164
+ const queryClause = queryTargets.length ? buildNameQueryFromTargets(queryTargets) : "";
165
+ const effectiveLimit = limit ?? Math.min(queryTargets.length * 5, 500);
166
+ const searchFields = ensureFieldsInclude(fields, ["Name"]);
167
+ const data = queryTargets.length
168
+ ? await clients.xivapi.search({
169
+ query: queryClause,
170
+ sheets: "Item",
171
+ limit: effectiveLimit,
172
+ language,
173
+ fields: searchFields,
174
+ })
175
+ : { results: [] };
176
+ const results = Array.isArray(data.results)
177
+ ? (data.results ?? [])
178
+ : [];
179
+ const namedResults = extractNamedResults(results);
180
+ const matches = targets.map((target) => {
181
+ const best = findBestResult(namedResults, target.name, target.matchMode);
182
+ const fieldsObj = best?.fields;
183
+ return {
184
+ input_name: target.sourceInput,
185
+ expanded_name: target.origin === "expanded" ? target.name : null,
186
+ matched_name: typeof fieldsObj?.Name === "string" ? fieldsObj.Name : null,
187
+ item_id: typeof best?.row_id === "number" ? best.row_id : null,
188
+ score: typeof best?.score === "number" ? best.score : null,
189
+ match_type: best ? (target.origin === "expanded" ? "expanded" : target.matchMode) : "none",
190
+ match_mode: target.matchMode,
191
+ resolution_source: target.origin,
192
+ };
193
+ });
194
+ const missingGradeMatches = missingGradeInputs.map((name) => ({
195
+ input_name: name,
196
+ expanded_name: null,
197
+ matched_name: null,
198
+ item_id: null,
199
+ score: null,
200
+ match_type: "missing_grade",
201
+ match_mode: match_mode,
202
+ resolution_source: "category",
203
+ notes: ["Materia category missing grade. Specify I-XII to expand."],
204
+ }));
205
+ const unresolvedDirect = targets
206
+ .map((target, index) => ({ target, index }))
207
+ .filter(({ target, index }) => target.origin === "direct" && target.matchMode === "exact" && matches[index]?.item_id == null);
208
+ let fallbackResults = [];
209
+ let fallbackQueryClause = null;
210
+ if (unresolvedDirect.length > 0) {
211
+ const fallbackTargets = dedupeNameTargets(unresolvedDirect.map(({ target }) => ({
212
+ name: target.name,
213
+ matchMode: "partial",
214
+ })));
215
+ fallbackQueryClause = buildNameQueryFromTargets(fallbackTargets);
216
+ const fallbackLimit = Math.min(fallbackTargets.length * 5, 500);
217
+ const fallbackData = await clients.xivapi.search({
218
+ query: fallbackQueryClause,
219
+ sheets: "Item",
220
+ limit: fallbackLimit,
221
+ language,
222
+ fields: searchFields,
223
+ });
224
+ fallbackResults = Array.isArray(fallbackData.results)
225
+ ? (fallbackData.results ?? [])
226
+ : [];
227
+ const fallbackNamedResults = extractNamedResults(fallbackResults);
228
+ for (const { target, index } of unresolvedDirect) {
229
+ const best = findBestResult(fallbackNamedResults, target.name, "partial");
230
+ if (!best)
231
+ continue;
232
+ const fieldsObj = best.fields;
233
+ matches[index] = {
234
+ input_name: target.sourceInput,
235
+ expanded_name: null,
236
+ matched_name: typeof fieldsObj?.Name === "string" ? fieldsObj.Name : null,
237
+ item_id: typeof best.row_id === "number" ? best.row_id : null,
238
+ score: typeof best.score === "number" ? best.score : null,
239
+ match_type: "fallback_partial",
240
+ match_mode: target.matchMode,
241
+ resolution_source: target.origin,
242
+ };
243
+ }
244
+ }
245
+ const allMatches = [...matches, ...missingGradeMatches];
246
+ const resolvedInputs = new Set(allMatches.filter((entry) => entry.item_id != null).map((entry) => entry.input_name));
247
+ const unmatched = names.filter((name) => !resolvedInputs.has(name));
248
+ const unmatchedExpanded = allMatches
249
+ .filter((entry) => entry.resolution_source === "expanded" && entry.item_id == null)
250
+ .map((entry) => entry.expanded_name ?? entry.input_name);
251
+ return buildToolResponse({
252
+ title: "Resolved Items (Bulk)",
253
+ responseFormat: response_format,
254
+ data: {
255
+ matches: allMatches,
256
+ unmatched,
257
+ unmatched_expanded: unmatchedExpanded,
258
+ results: results.concat(fallbackResults),
259
+ expanded_items: expandedItems,
260
+ missing_expansions: missingGradeInputs,
261
+ },
262
+ meta: {
263
+ source: "xivapi",
264
+ endpoint: "/search",
265
+ query: queryClause,
266
+ ...(fallbackQueryClause ? { fallback_query: fallbackQueryClause } : {}),
267
+ limit: effectiveLimit,
68
268
  ...(language ? { language } : {}),
69
269
  },
70
270
  });
@@ -107,4 +307,46 @@ export function registerLookupTools(server, clients) {
107
307
  },
108
308
  });
109
309
  });
310
+ server.registerTool("universalis_get_items_by_ids", {
311
+ title: "Get Items by IDs (XIVAPI)",
312
+ description: "Fetch multiple item rows from XIVAPI by item IDs.",
313
+ inputSchema: z
314
+ .object({
315
+ item_ids: z
316
+ .array(z.number().int().min(1))
317
+ .min(1)
318
+ .max(200)
319
+ .describe("Item IDs to fetch (max 200 per call)."),
320
+ language: LanguageSchema,
321
+ fields: z
322
+ .string()
323
+ .optional()
324
+ .describe("Comma-separated XIVAPI fields to select."),
325
+ response_format: ResponseFormatSchema,
326
+ })
327
+ .strict(),
328
+ outputSchema: BaseOutputSchema,
329
+ annotations: {
330
+ readOnlyHint: true,
331
+ destructiveHint: false,
332
+ idempotentHint: true,
333
+ openWorldHint: true,
334
+ },
335
+ }, async ({ item_ids, language, fields, response_format }) => {
336
+ const data = await clients.xivapi.getItemsByIds(item_ids, {
337
+ language,
338
+ fields,
339
+ });
340
+ return buildToolResponse({
341
+ title: "Item Details (Bulk)",
342
+ responseFormat: response_format,
343
+ data,
344
+ meta: {
345
+ source: "xivapi",
346
+ endpoint: "/sheet/Item",
347
+ item_ids: item_ids,
348
+ ...(language ? { language } : {}),
349
+ },
350
+ });
351
+ });
110
352
  }