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.
- package/README.md +30 -2
- package/data/materia.json +347 -0
- package/dist/constants.js +3 -0
- package/dist/index.js +22 -0
- package/dist/instructions.js +56 -0
- package/dist/services/clients.js +13 -1
- package/dist/services/materia.js +189 -0
- package/dist/services/saddlebag.js +25 -0
- package/dist/services/xivapi.js +50 -0
- package/dist/tools/lookup.js +246 -4
- package/dist/tools/saddlebag.js +682 -0
- package/dist/tools/workflows.js +435 -0
- package/dist/utils/array.js +9 -0
- package/dist/utils/materia.js +71 -0
- package/dist/utils/xivapi.js +71 -0
- package/package.json +4 -2
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { DEFAULT_MATERIA_CACHE_TTL_MS } from "../constants.js";
|
|
5
|
+
import { extractMateriaGrade, parseMateriaCategoryInput } from "../utils/materia.js";
|
|
6
|
+
const DEFAULT_MATERIA_FIELDS = "BaseParam.Name,Item[].Name";
|
|
7
|
+
const DEFAULT_MATERIA_LIMIT = 200;
|
|
8
|
+
const craftingParams = new Set(["Craftsmanship", "Control", "CP"]);
|
|
9
|
+
const gatheringParams = new Set(["Gathering", "Perception", "GP"]);
|
|
10
|
+
const defaultIndex = {
|
|
11
|
+
generated_at: new Date(0).toISOString(),
|
|
12
|
+
categories: {
|
|
13
|
+
combat: {},
|
|
14
|
+
crafting: {},
|
|
15
|
+
gathering: {},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const dataPath = (() => {
|
|
19
|
+
if (process.env.MATERIA_DATA_PATH) {
|
|
20
|
+
return path.resolve(process.env.MATERIA_DATA_PATH);
|
|
21
|
+
}
|
|
22
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
return path.resolve(currentDir, "../../data/materia.json");
|
|
24
|
+
})();
|
|
25
|
+
let cachedIndex = null;
|
|
26
|
+
let refreshPromise = null;
|
|
27
|
+
function parseTtlMs() {
|
|
28
|
+
const raw = process.env.MATERIA_CACHE_TTL_MS;
|
|
29
|
+
if (!raw)
|
|
30
|
+
return DEFAULT_MATERIA_CACHE_TTL_MS;
|
|
31
|
+
const parsed = Number(raw);
|
|
32
|
+
return Number.isNaN(parsed) ? DEFAULT_MATERIA_CACHE_TTL_MS : parsed;
|
|
33
|
+
}
|
|
34
|
+
function isRefreshEnabled() {
|
|
35
|
+
const raw = process.env.MATERIA_REFRESH;
|
|
36
|
+
if (!raw)
|
|
37
|
+
return true;
|
|
38
|
+
return raw.toLowerCase() !== "false";
|
|
39
|
+
}
|
|
40
|
+
function isStale(index) {
|
|
41
|
+
if (!index?.generated_at)
|
|
42
|
+
return true;
|
|
43
|
+
const generatedAt = Date.parse(index.generated_at);
|
|
44
|
+
if (Number.isNaN(generatedAt))
|
|
45
|
+
return true;
|
|
46
|
+
return Date.now() - generatedAt > parseTtlMs();
|
|
47
|
+
}
|
|
48
|
+
function categorizeBaseParam(name) {
|
|
49
|
+
if (craftingParams.has(name))
|
|
50
|
+
return "crafting";
|
|
51
|
+
if (gatheringParams.has(name))
|
|
52
|
+
return "gathering";
|
|
53
|
+
return "combat";
|
|
54
|
+
}
|
|
55
|
+
async function loadMateriaIndexFromDisk() {
|
|
56
|
+
try {
|
|
57
|
+
const raw = await readFile(dataPath, "utf8");
|
|
58
|
+
const parsed = JSON.parse(raw);
|
|
59
|
+
if (!parsed?.categories)
|
|
60
|
+
return null;
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function saveMateriaIndex(index) {
|
|
68
|
+
const payload = JSON.stringify(index, null, 2);
|
|
69
|
+
await mkdir(path.dirname(dataPath), { recursive: true });
|
|
70
|
+
await writeFile(dataPath, `${payload}\n`, "utf8");
|
|
71
|
+
}
|
|
72
|
+
async function fetchMateriaIndex(xivapi) {
|
|
73
|
+
const categories = {
|
|
74
|
+
combat: {},
|
|
75
|
+
crafting: {},
|
|
76
|
+
gathering: {},
|
|
77
|
+
};
|
|
78
|
+
const baseParamCategory = {};
|
|
79
|
+
let after;
|
|
80
|
+
let version;
|
|
81
|
+
while (true) {
|
|
82
|
+
const data = await xivapi.getSheetRows("Materia", {
|
|
83
|
+
limit: DEFAULT_MATERIA_LIMIT,
|
|
84
|
+
after,
|
|
85
|
+
fields: DEFAULT_MATERIA_FIELDS,
|
|
86
|
+
});
|
|
87
|
+
if (!data || !Array.isArray(data.rows) || data.rows.length === 0) {
|
|
88
|
+
if (!version && typeof data?.version === "string") {
|
|
89
|
+
version = data.version;
|
|
90
|
+
}
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (!version && typeof data.version === "string") {
|
|
94
|
+
version = data.version;
|
|
95
|
+
}
|
|
96
|
+
for (const row of data.rows) {
|
|
97
|
+
const baseParamName = row.fields?.BaseParam?.fields?.Name;
|
|
98
|
+
if (!baseParamName)
|
|
99
|
+
continue;
|
|
100
|
+
const category = categorizeBaseParam(baseParamName);
|
|
101
|
+
baseParamCategory[baseParamName] = category;
|
|
102
|
+
const items = row.fields?.Item ?? [];
|
|
103
|
+
for (const item of items) {
|
|
104
|
+
const itemName = item.fields?.Name;
|
|
105
|
+
if (!itemName)
|
|
106
|
+
continue;
|
|
107
|
+
const grade = extractMateriaGrade(itemName);
|
|
108
|
+
if (!grade)
|
|
109
|
+
continue;
|
|
110
|
+
const gradeBucket = categories[category][grade] ?? [];
|
|
111
|
+
if (!gradeBucket.includes(itemName)) {
|
|
112
|
+
gradeBucket.push(itemName);
|
|
113
|
+
}
|
|
114
|
+
categories[category][grade] = gradeBucket;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const lastRow = data.rows[data.rows.length - 1];
|
|
118
|
+
const lastId = lastRow?.row_id;
|
|
119
|
+
if (!lastId || data.rows.length < DEFAULT_MATERIA_LIMIT) {
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
if (after === lastId) {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
after = lastId;
|
|
126
|
+
}
|
|
127
|
+
for (const category of Object.keys(categories)) {
|
|
128
|
+
for (const grade of Object.keys(categories[category])) {
|
|
129
|
+
categories[category][grade] = categories[category][grade].sort();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
generated_at: new Date().toISOString(),
|
|
134
|
+
...(version ? { xivapi_version: version } : {}),
|
|
135
|
+
categories,
|
|
136
|
+
base_param_category: baseParamCategory,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
async function refreshIndex(xivapi, waitForResult) {
|
|
140
|
+
if (!isRefreshEnabled()) {
|
|
141
|
+
cachedIndex = cachedIndex ?? defaultIndex;
|
|
142
|
+
return cachedIndex;
|
|
143
|
+
}
|
|
144
|
+
if (refreshPromise) {
|
|
145
|
+
return waitForResult ? refreshPromise : cachedIndex ?? defaultIndex;
|
|
146
|
+
}
|
|
147
|
+
refreshPromise = (async () => {
|
|
148
|
+
try {
|
|
149
|
+
const index = await fetchMateriaIndex(xivapi);
|
|
150
|
+
cachedIndex = index;
|
|
151
|
+
await saveMateriaIndex(index);
|
|
152
|
+
return index;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
cachedIndex = cachedIndex ?? defaultIndex;
|
|
156
|
+
return cachedIndex;
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
refreshPromise = null;
|
|
160
|
+
}
|
|
161
|
+
})();
|
|
162
|
+
return waitForResult ? refreshPromise : cachedIndex ?? defaultIndex;
|
|
163
|
+
}
|
|
164
|
+
async function getMateriaIndex(xivapi) {
|
|
165
|
+
if (!cachedIndex) {
|
|
166
|
+
cachedIndex = (await loadMateriaIndexFromDisk()) ?? null;
|
|
167
|
+
}
|
|
168
|
+
if (!cachedIndex) {
|
|
169
|
+
return refreshIndex(xivapi, true);
|
|
170
|
+
}
|
|
171
|
+
if (isStale(cachedIndex)) {
|
|
172
|
+
void refreshIndex(xivapi, false);
|
|
173
|
+
}
|
|
174
|
+
return cachedIndex;
|
|
175
|
+
}
|
|
176
|
+
export async function expandMateriaCategory(input, xivapi) {
|
|
177
|
+
const parsed = parseMateriaCategoryInput(input);
|
|
178
|
+
if (!parsed)
|
|
179
|
+
return null;
|
|
180
|
+
const index = await getMateriaIndex(xivapi);
|
|
181
|
+
const expandedNames = parsed.grade
|
|
182
|
+
? index.categories[parsed.category]?.[parsed.grade] ?? []
|
|
183
|
+
: [];
|
|
184
|
+
return {
|
|
185
|
+
category: parsed.category,
|
|
186
|
+
grade: parsed.grade,
|
|
187
|
+
expandedNames,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SADDLEBAG_BASE_URL } from "../constants.js";
|
|
2
|
+
import { requestJson } from "./http.js";
|
|
3
|
+
export class SaddlebagClient {
|
|
4
|
+
baseUrl;
|
|
5
|
+
timeoutMs;
|
|
6
|
+
limiter;
|
|
7
|
+
userAgent;
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.baseUrl = options.baseUrl ?? SADDLEBAG_BASE_URL;
|
|
10
|
+
this.timeoutMs = options.timeoutMs;
|
|
11
|
+
this.limiter = options.limiter;
|
|
12
|
+
this.userAgent = options.userAgent;
|
|
13
|
+
}
|
|
14
|
+
async post(path, body) {
|
|
15
|
+
return requestJson({
|
|
16
|
+
baseUrl: this.baseUrl,
|
|
17
|
+
path,
|
|
18
|
+
method: "POST",
|
|
19
|
+
body,
|
|
20
|
+
limiter: this.limiter,
|
|
21
|
+
timeoutMs: this.timeoutMs,
|
|
22
|
+
userAgent: this.userAgent,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/services/xivapi.js
CHANGED
|
@@ -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,
|
package/dist/tools/lookup.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
|
51
|
-
const
|
|
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
|
}
|