universalis-mcp-server 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 Jakub Mucha
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,103 @@
1
+ # universalis-mcp-server
2
+
3
+ MCP server for Universalis market data and XIVAPI item lookup.
4
+
5
+ ## Capabilities
6
+
7
+ This MCP server exposes read-only tools for Universalis market data and XIVAPI item lookup.
8
+ All tools support `response_format` as `markdown` or `json`.
9
+
10
+ ### Market board data (Universalis)
11
+
12
+ - `universalis_get_aggregated_prices`: Aggregated pricing stats for up to 100 item IDs on a world, data center, or region (cached values only).
13
+ - `universalis_get_current_listings`: Current listings plus recent sales history; supports filters like `hq`, `listings`, `entries`, and field selection.
14
+ - `universalis_get_sales_history`: Sales history with optional time windows and price filters.
15
+
16
+ ### Item lookup (XIVAPI)
17
+
18
+ - `universalis_resolve_items_by_name`: Search items by name with `partial` or `exact` matching.
19
+ - `universalis_get_item_by_id`: Fetch a single item row by ID.
20
+
21
+ ### Reference data (Universalis)
22
+
23
+ - `universalis_list_worlds`: List worlds with pagination.
24
+ - `universalis_list_data_centers`: List data centers with pagination.
25
+ - `universalis_list_marketable_items`: List marketable item IDs with pagination.
26
+ - `universalis_get_tax_rates`: Current tax rates for a world.
27
+ - `universalis_get_list`: Fetch a Universalis list by ID.
28
+ - `universalis_get_content`: Fetch content metadata by ID (best-effort endpoint).
29
+
30
+ ### Stats and activity (Universalis)
31
+
32
+ - `universalis_get_most_recent_updates`: Most recently updated items for a world or data center.
33
+ - `universalis_get_least_recent_updates`: Least recently updated items for a world or data center.
34
+ - `universalis_get_recent_updates`: Legacy list of recent updates (no world/DC scoping).
35
+ - `universalis_get_upload_counts_by_source`: Upload counts by client app.
36
+ - `universalis_get_upload_counts_by_world`: Upload counts and proportions by world.
37
+ - `universalis_get_upload_history`: Daily upload totals for the last 30 days.
38
+
39
+ ## Use with Codex and Claude Code
40
+
41
+ Install from npm via MCP:
42
+
43
+ ```bash
44
+ # Codex
45
+ codex mcp add universalis-mcp-server -- npx universalis-mcp-server@latest
46
+
47
+ # Claude Code
48
+ claude mcp add universalis-mcp-server npx universalis-mcp-server@latest
49
+ ```
50
+
51
+ To pass environment variables (optional), add them before the command:
52
+
53
+ ```bash
54
+ # Codex
55
+ codex mcp add universalis-mcp-server -- env UNIVERSALIS_TIMEOUT_MS=30000 XIVAPI_TIMEOUT_MS=30000 npx universalis-mcp-server@latest
56
+
57
+ # Claude Code
58
+ claude mcp add universalis-mcp-server env UNIVERSALIS_TIMEOUT_MS=30000 XIVAPI_TIMEOUT_MS=30000 npx universalis-mcp-server@latest
59
+ ```
60
+
61
+ ## Setup
62
+
63
+ ```bash
64
+ pnpm install
65
+ pnpm build
66
+ ```
67
+
68
+ ## Run (stdio)
69
+
70
+ ```bash
71
+ node dist/index.js
72
+ ```
73
+
74
+ ## Local Development with Codex and Claude Code
75
+
76
+ Build the server and point MCP to the local `dist` entry:
77
+
78
+ ```bash
79
+ pnpm build
80
+
81
+ # Codex
82
+ codex mcp add universalis-mcp-server -- node ./dist/index.js
83
+
84
+ # Claude Code
85
+ claude mcp add universalis-mcp-server node ./dist/index.js
86
+ ```
87
+
88
+ If you change code, re-run `pnpm build` and restart the MCP connection.
89
+
90
+ ## Environment Variables
91
+
92
+ - `UNIVERSALIS_BASE_URL`: Override Universalis base URL (default: `https://universalis.app/api/v2`).
93
+ - `XIVAPI_BASE_URL`: Override XIVAPI base URL (default: `https://v2.xivapi.com/api`).
94
+ - `UNIVERSALIS_MCP_USER_AGENT`: Custom User-Agent header.
95
+ - `UNIVERSALIS_TIMEOUT_MS`: Request timeout for Universalis (default: 30000).
96
+ - `XIVAPI_TIMEOUT_MS`: Request timeout for XIVAPI (default: 30000).
97
+ - `XIVAPI_LANGUAGE`: Default XIVAPI language (default: `en`).
98
+ - `XIVAPI_VERSION`: Default XIVAPI version (default: `latest`).
99
+
100
+ ## Notes
101
+
102
+ - Rate limits are enforced client-side for Universalis and XIVAPI.
103
+ - Tools support `response_format` as `markdown` or `json`.
@@ -0,0 +1,10 @@
1
+ export const UNIVERSALIS_BASE_URL = process.env.UNIVERSALIS_BASE_URL ?? "https://universalis.app/api/v2";
2
+ export const XIVAPI_BASE_URL = process.env.XIVAPI_BASE_URL ?? "https://v2.xivapi.com/api";
3
+ export const DEFAULT_UNIVERSALIS_TIMEOUT_MS = 30000;
4
+ export const DEFAULT_XIVAPI_TIMEOUT_MS = 30000;
5
+ export const DEFAULT_TIMEOUT_MS = 30000;
6
+ export const CHARACTER_LIMIT = 25000;
7
+ export const DEFAULT_PAGE_LIMIT = 20;
8
+ export const MAX_PAGE_LIMIT = 100;
9
+ export const DEFAULT_XIVAPI_LANGUAGE = process.env.XIVAPI_LANGUAGE ?? "en";
10
+ export const DEFAULT_XIVAPI_VERSION = process.env.XIVAPI_VERSION ?? "latest";
package/dist/index.js ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import pkg from "../package.json" with { type: "json" };
5
+ import { createClients } from "./services/clients.js";
6
+ import { registerLookupTools } from "./tools/lookup.js";
7
+ import { registerMarketTools } from "./tools/market.js";
8
+ import { registerReferenceTools } from "./tools/reference.js";
9
+ import { registerStatsTools } from "./tools/stats.js";
10
+ function toNumber(value) {
11
+ if (!value)
12
+ return undefined;
13
+ const parsed = Number(value);
14
+ return Number.isNaN(parsed) ? undefined : parsed;
15
+ }
16
+ async function main() {
17
+ const server = new McpServer({
18
+ name: "universalis-mcp-server",
19
+ version: pkg.version,
20
+ });
21
+ const userAgent = process.env.UNIVERSALIS_MCP_USER_AGENT ?? `universalis-mcp-server/${pkg.version}`;
22
+ const clients = createClients({
23
+ userAgent,
24
+ universalisTimeoutMs: toNumber(process.env.UNIVERSALIS_TIMEOUT_MS),
25
+ xivapiTimeoutMs: toNumber(process.env.XIVAPI_TIMEOUT_MS),
26
+ xivapiLanguage: process.env.XIVAPI_LANGUAGE,
27
+ xivapiVersion: process.env.XIVAPI_VERSION,
28
+ });
29
+ registerMarketTools(server, clients);
30
+ registerReferenceTools(server, clients);
31
+ registerStatsTools(server, clients);
32
+ registerLookupTools(server, clients);
33
+ const transport = new StdioServerTransport();
34
+ await server.connect(transport);
35
+ console.error("universalis-mcp-server running on stdio");
36
+ }
37
+ main().catch((error) => {
38
+ console.error("Fatal error in main():", error);
39
+ process.exit(1);
40
+ });
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ export var ResponseFormat;
3
+ (function (ResponseFormat) {
4
+ ResponseFormat["MARKDOWN"] = "markdown";
5
+ ResponseFormat["JSON"] = "json";
6
+ })(ResponseFormat || (ResponseFormat = {}));
7
+ export const ResponseFormatSchema = z
8
+ .nativeEnum(ResponseFormat)
9
+ .default(ResponseFormat.MARKDOWN)
10
+ .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable.");
11
+ export const BaseOutputSchema = z.object({
12
+ data: z.unknown(),
13
+ meta: z.record(z.string(), z.unknown()).optional(),
14
+ });
@@ -0,0 +1,32 @@
1
+ import Bottleneck from "bottleneck";
2
+ import { DEFAULT_UNIVERSALIS_TIMEOUT_MS, DEFAULT_XIVAPI_TIMEOUT_MS } from "../constants.js";
3
+ import { UniversalisClient } from "./universalis.js";
4
+ import { XivapiClient } from "./xivapi.js";
5
+ export function createClients(options = {}) {
6
+ const universalisLimiter = new Bottleneck({
7
+ maxConcurrent: 8,
8
+ reservoir: 50,
9
+ reservoirRefreshAmount: 25,
10
+ reservoirRefreshInterval: 1000,
11
+ });
12
+ const xivapiLimiter = new Bottleneck({
13
+ maxConcurrent: 4,
14
+ reservoir: 20,
15
+ reservoirRefreshAmount: 10,
16
+ reservoirRefreshInterval: 1000,
17
+ });
18
+ return {
19
+ universalis: new UniversalisClient({
20
+ limiter: universalisLimiter,
21
+ timeoutMs: options.universalisTimeoutMs ?? DEFAULT_UNIVERSALIS_TIMEOUT_MS,
22
+ userAgent: options.userAgent,
23
+ }),
24
+ xivapi: new XivapiClient({
25
+ limiter: xivapiLimiter,
26
+ timeoutMs: options.xivapiTimeoutMs ?? DEFAULT_XIVAPI_TIMEOUT_MS,
27
+ userAgent: options.userAgent,
28
+ defaultLanguage: options.xivapiLanguage,
29
+ defaultVersion: options.xivapiVersion,
30
+ }),
31
+ };
32
+ }
@@ -0,0 +1,93 @@
1
+ import { DEFAULT_TIMEOUT_MS } from "../constants.js";
2
+ export class ApiError extends Error {
3
+ status;
4
+ details;
5
+ constructor(message, status, details) {
6
+ super(message);
7
+ this.name = "ApiError";
8
+ this.status = status;
9
+ this.details = details;
10
+ }
11
+ }
12
+ const arrayJoiner = {
13
+ query: " ",
14
+ fields: ",",
15
+ transient: ",",
16
+ };
17
+ function buildUrl(baseUrl, path, query) {
18
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
19
+ const url = new URL(path.replace(/^\/+/, ""), base);
20
+ if (!query)
21
+ return url;
22
+ const params = new URLSearchParams();
23
+ for (const [key, value] of Object.entries(query)) {
24
+ if (value === undefined || value === null)
25
+ continue;
26
+ if (Array.isArray(value)) {
27
+ const joiner = arrayJoiner[key] ?? ",";
28
+ params.set(key, value.join(joiner));
29
+ }
30
+ else {
31
+ params.set(key, String(value));
32
+ }
33
+ }
34
+ url.search = params.toString();
35
+ return url;
36
+ }
37
+ async function doRequest({ baseUrl, path, method = "GET", query, headers, body, timeoutMs = DEFAULT_TIMEOUT_MS, userAgent, responseType = "json", }) {
38
+ const url = buildUrl(baseUrl, path, query);
39
+ const controller = new AbortController();
40
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
41
+ try {
42
+ const response = await fetch(url, {
43
+ method,
44
+ headers: {
45
+ Accept: responseType === "json" ? "application/json" : "*/*",
46
+ ...(body ? { "Content-Type": "application/json" } : {}),
47
+ ...(userAgent ? { "User-Agent": userAgent } : {}),
48
+ ...headers,
49
+ },
50
+ body: body ? JSON.stringify(body) : undefined,
51
+ signal: controller.signal,
52
+ });
53
+ const contentType = response.headers.get("content-type") ?? "";
54
+ const rawText = await response.text();
55
+ if (!response.ok) {
56
+ const parsed = contentType.includes("application/json") ? safeJsonParse(rawText) : undefined;
57
+ const message = (parsed && typeof parsed === "object" && parsed && "message" in parsed && String(parsed.message)) ||
58
+ rawText ||
59
+ `Request failed with status ${response.status}`;
60
+ throw new ApiError(message, response.status, parsed ?? rawText);
61
+ }
62
+ if (responseType === "text" || !contentType.includes("application/json")) {
63
+ return rawText;
64
+ }
65
+ return safeJsonParse(rawText);
66
+ }
67
+ catch (error) {
68
+ if (error instanceof ApiError) {
69
+ throw error;
70
+ }
71
+ const message = error instanceof Error ? error.message : "Unknown request error";
72
+ throw new ApiError(message, 0);
73
+ }
74
+ finally {
75
+ clearTimeout(timeout);
76
+ }
77
+ }
78
+ function safeJsonParse(payload) {
79
+ if (!payload)
80
+ return null;
81
+ try {
82
+ return JSON.parse(payload);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ export async function requestJson(options) {
89
+ if (options.limiter) {
90
+ return options.limiter.schedule(() => doRequest(options));
91
+ }
92
+ return doRequest(options);
93
+ }
@@ -0,0 +1,179 @@
1
+ import { LRUCache } from "lru-cache";
2
+ import { UNIVERSALIS_BASE_URL } from "../constants.js";
3
+ import { requestJson } from "./http.js";
4
+ export class UniversalisClient {
5
+ baseUrl;
6
+ timeoutMs;
7
+ limiter;
8
+ userAgent;
9
+ worldsCache = new LRUCache({ max: 1, ttl: 1000 * 60 * 60 });
10
+ dataCentersCache = new LRUCache({
11
+ max: 1,
12
+ ttl: 1000 * 60 * 60,
13
+ });
14
+ marketableCache = new LRUCache({ max: 1, ttl: 1000 * 60 * 60 * 6 });
15
+ constructor(options = {}) {
16
+ this.baseUrl = options.baseUrl ?? UNIVERSALIS_BASE_URL;
17
+ this.timeoutMs = options.timeoutMs;
18
+ this.limiter = options.limiter;
19
+ this.userAgent = options.userAgent;
20
+ }
21
+ async listWorlds() {
22
+ const cached = this.worldsCache.get("worlds");
23
+ if (cached)
24
+ return cached;
25
+ const data = await requestJson({
26
+ baseUrl: this.baseUrl,
27
+ path: "/worlds",
28
+ limiter: this.limiter,
29
+ timeoutMs: this.timeoutMs,
30
+ userAgent: this.userAgent,
31
+ });
32
+ this.worldsCache.set("worlds", data);
33
+ return data;
34
+ }
35
+ async listDataCenters() {
36
+ const cached = this.dataCentersCache.get("data-centers");
37
+ if (cached)
38
+ return cached;
39
+ const data = await requestJson({
40
+ baseUrl: this.baseUrl,
41
+ path: "/data-centers",
42
+ limiter: this.limiter,
43
+ timeoutMs: this.timeoutMs,
44
+ userAgent: this.userAgent,
45
+ });
46
+ this.dataCentersCache.set("data-centers", data);
47
+ return data;
48
+ }
49
+ async getAggregatedMarketData(worldDcRegion, itemIds) {
50
+ const path = `/aggregated/${encodeURIComponent(worldDcRegion)}/${itemIds.join(",")}`;
51
+ return requestJson({
52
+ baseUrl: this.baseUrl,
53
+ path,
54
+ limiter: this.limiter,
55
+ timeoutMs: this.timeoutMs,
56
+ userAgent: this.userAgent,
57
+ });
58
+ }
59
+ async getCurrentMarketData(worldDcRegion, itemIds, query) {
60
+ const path = `/${encodeURIComponent(worldDcRegion)}/${itemIds.join(",")}`;
61
+ return requestJson({
62
+ baseUrl: this.baseUrl,
63
+ path,
64
+ query,
65
+ limiter: this.limiter,
66
+ timeoutMs: this.timeoutMs,
67
+ userAgent: this.userAgent,
68
+ });
69
+ }
70
+ async getSalesHistory(worldDcRegion, itemIds, query) {
71
+ const path = `/history/${encodeURIComponent(worldDcRegion)}/${itemIds.join(",")}`;
72
+ return requestJson({
73
+ baseUrl: this.baseUrl,
74
+ path,
75
+ query,
76
+ limiter: this.limiter,
77
+ timeoutMs: this.timeoutMs,
78
+ userAgent: this.userAgent,
79
+ });
80
+ }
81
+ async getTaxRates(world) {
82
+ return requestJson({
83
+ baseUrl: this.baseUrl,
84
+ path: "/tax-rates",
85
+ query: { world },
86
+ limiter: this.limiter,
87
+ timeoutMs: this.timeoutMs,
88
+ userAgent: this.userAgent,
89
+ });
90
+ }
91
+ async listMarketableItems() {
92
+ const cached = this.marketableCache.get("marketable");
93
+ if (cached)
94
+ return cached;
95
+ const data = await requestJson({
96
+ baseUrl: this.baseUrl,
97
+ path: "/marketable",
98
+ limiter: this.limiter,
99
+ timeoutMs: this.timeoutMs,
100
+ userAgent: this.userAgent,
101
+ });
102
+ this.marketableCache.set("marketable", data);
103
+ return data;
104
+ }
105
+ async getMostRecentlyUpdated(world, dcName, entries) {
106
+ return requestJson({
107
+ baseUrl: this.baseUrl,
108
+ path: "/extra/stats/most-recently-updated",
109
+ query: { world, dcName, entries },
110
+ limiter: this.limiter,
111
+ timeoutMs: this.timeoutMs,
112
+ userAgent: this.userAgent,
113
+ });
114
+ }
115
+ async getLeastRecentlyUpdated(world, dcName, entries) {
116
+ return requestJson({
117
+ baseUrl: this.baseUrl,
118
+ path: "/extra/stats/least-recently-updated",
119
+ query: { world, dcName, entries },
120
+ limiter: this.limiter,
121
+ timeoutMs: this.timeoutMs,
122
+ userAgent: this.userAgent,
123
+ });
124
+ }
125
+ async getRecentlyUpdated() {
126
+ return requestJson({
127
+ baseUrl: this.baseUrl,
128
+ path: "/extra/stats/recently-updated",
129
+ limiter: this.limiter,
130
+ timeoutMs: this.timeoutMs,
131
+ userAgent: this.userAgent,
132
+ });
133
+ }
134
+ async getUploaderUploadCounts() {
135
+ return requestJson({
136
+ baseUrl: this.baseUrl,
137
+ path: "/extra/stats/uploader-upload-counts",
138
+ limiter: this.limiter,
139
+ timeoutMs: this.timeoutMs,
140
+ userAgent: this.userAgent,
141
+ });
142
+ }
143
+ async getWorldUploadCounts() {
144
+ return requestJson({
145
+ baseUrl: this.baseUrl,
146
+ path: "/extra/stats/world-upload-counts",
147
+ limiter: this.limiter,
148
+ timeoutMs: this.timeoutMs,
149
+ userAgent: this.userAgent,
150
+ });
151
+ }
152
+ async getUploadHistory() {
153
+ return requestJson({
154
+ baseUrl: this.baseUrl,
155
+ path: "/extra/stats/upload-history",
156
+ limiter: this.limiter,
157
+ timeoutMs: this.timeoutMs,
158
+ userAgent: this.userAgent,
159
+ });
160
+ }
161
+ async getList(listId) {
162
+ return requestJson({
163
+ baseUrl: this.baseUrl,
164
+ path: `/lists/${encodeURIComponent(listId)}`,
165
+ limiter: this.limiter,
166
+ timeoutMs: this.timeoutMs,
167
+ userAgent: this.userAgent,
168
+ });
169
+ }
170
+ async getContent(contentId) {
171
+ return requestJson({
172
+ baseUrl: this.baseUrl,
173
+ path: `/extra/content/${encodeURIComponent(contentId)}`,
174
+ limiter: this.limiter,
175
+ timeoutMs: this.timeoutMs,
176
+ userAgent: this.userAgent,
177
+ });
178
+ }
179
+ }
@@ -0,0 +1,62 @@
1
+ import { LRUCache } from "lru-cache";
2
+ import { DEFAULT_XIVAPI_LANGUAGE, DEFAULT_XIVAPI_VERSION, XIVAPI_BASE_URL } from "../constants.js";
3
+ import { requestJson } from "./http.js";
4
+ export class XivapiClient {
5
+ baseUrl;
6
+ timeoutMs;
7
+ limiter;
8
+ userAgent;
9
+ defaultLanguage;
10
+ defaultVersion;
11
+ itemCache = new LRUCache({ max: 500, ttl: 1000 * 60 * 60 * 24 });
12
+ searchCache = new LRUCache({ max: 200, ttl: 1000 * 60 * 15 });
13
+ constructor(options = {}) {
14
+ this.baseUrl = options.baseUrl ?? XIVAPI_BASE_URL;
15
+ this.timeoutMs = options.timeoutMs;
16
+ this.limiter = options.limiter;
17
+ this.userAgent = options.userAgent;
18
+ this.defaultLanguage = options.defaultLanguage ?? DEFAULT_XIVAPI_LANGUAGE;
19
+ this.defaultVersion = options.defaultVersion ?? DEFAULT_XIVAPI_VERSION;
20
+ }
21
+ async search(params) {
22
+ const normalized = this.withDefaults({ ...params });
23
+ const cacheKey = JSON.stringify(normalized);
24
+ const cached = this.searchCache.get(cacheKey);
25
+ if (cached)
26
+ return cached;
27
+ const data = await requestJson({
28
+ baseUrl: this.baseUrl,
29
+ path: "/search",
30
+ query: normalized,
31
+ limiter: this.limiter,
32
+ timeoutMs: this.timeoutMs,
33
+ userAgent: this.userAgent,
34
+ });
35
+ this.searchCache.set(cacheKey, data);
36
+ return data;
37
+ }
38
+ async getItemById(itemId, params = {}) {
39
+ const normalized = this.withDefaults({ ...params });
40
+ const cacheKey = JSON.stringify({ itemId, ...normalized });
41
+ const cached = this.itemCache.get(cacheKey);
42
+ if (cached)
43
+ return cached;
44
+ const data = await requestJson({
45
+ baseUrl: this.baseUrl,
46
+ path: `/sheet/Item/${itemId}`,
47
+ query: normalized,
48
+ limiter: this.limiter,
49
+ timeoutMs: this.timeoutMs,
50
+ userAgent: this.userAgent,
51
+ });
52
+ this.itemCache.set(cacheKey, data);
53
+ return data;
54
+ }
55
+ withDefaults(params) {
56
+ return {
57
+ ...params,
58
+ language: params.language ?? this.defaultLanguage,
59
+ version: params.version ?? this.defaultVersion,
60
+ };
61
+ }
62
+ }
@@ -0,0 +1,110 @@
1
+ import { z } from "zod";
2
+ import { BaseOutputSchema, ResponseFormatSchema } from "../schemas/common.js";
3
+ import { buildToolResponse } from "../utils/format.js";
4
+ const LanguageSchema = z
5
+ .enum(["none", "ja", "en", "de", "fr", "chs", "cht", "kr"])
6
+ .optional()
7
+ .describe("XIVAPI language code.");
8
+ const MatchModeSchema = z
9
+ .enum(["partial", "exact"])
10
+ .default("partial")
11
+ .describe("Match mode for item name queries.");
12
+ const DefaultItemFields = "Name,Icon,ItemSearchCategory,LevelItem";
13
+ function escapeQueryValue(value) {
14
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
15
+ }
16
+ export function registerLookupTools(server, clients) {
17
+ server.registerTool("universalis_resolve_items_by_name", {
18
+ title: "Resolve Items by Name (XIVAPI)",
19
+ description: "Search XIVAPI for items by name, returning matching rows and relevance scores.",
20
+ inputSchema: z
21
+ .object({
22
+ query: z
23
+ .string()
24
+ .min(1)
25
+ .describe('Item name to search for. Example: "Dark Matter".'),
26
+ match_mode: MatchModeSchema,
27
+ limit: z
28
+ .number()
29
+ .int()
30
+ .min(1)
31
+ .max(100)
32
+ .default(20)
33
+ .describe("Maximum results to return (default: 20)."),
34
+ language: LanguageSchema,
35
+ fields: z
36
+ .string()
37
+ .optional()
38
+ .describe("Comma-separated XIVAPI fields. Default: Name,Icon,ItemSearchCategory,LevelItem."),
39
+ response_format: ResponseFormatSchema,
40
+ })
41
+ .strict(),
42
+ outputSchema: BaseOutputSchema,
43
+ annotations: {
44
+ readOnlyHint: true,
45
+ destructiveHint: false,
46
+ idempotentHint: true,
47
+ openWorldHint: true,
48
+ },
49
+ }, async ({ query, match_mode, limit, language, fields, response_format }) => {
50
+ const escaped = escapeQueryValue(query);
51
+ const queryClause = match_mode === "exact" ? `Name="${escaped}"` : `Name~"${escaped}"`;
52
+ const data = await clients.xivapi.search({
53
+ query: queryClause,
54
+ sheets: "Item",
55
+ limit,
56
+ language,
57
+ fields: fields ?? DefaultItemFields,
58
+ });
59
+ return buildToolResponse({
60
+ title: "Resolved Items",
61
+ responseFormat: response_format,
62
+ data,
63
+ meta: {
64
+ source: "xivapi",
65
+ endpoint: "/search",
66
+ query: queryClause,
67
+ limit,
68
+ ...(language ? { language } : {}),
69
+ },
70
+ });
71
+ });
72
+ server.registerTool("universalis_get_item_by_id", {
73
+ title: "Get Item by ID (XIVAPI)",
74
+ description: "Fetch a single item row from XIVAPI by item ID.",
75
+ inputSchema: z
76
+ .object({
77
+ item_id: z.number().int().min(1).describe("Item ID. Example: 5333."),
78
+ language: LanguageSchema,
79
+ fields: z
80
+ .string()
81
+ .optional()
82
+ .describe("Comma-separated XIVAPI fields to select."),
83
+ response_format: ResponseFormatSchema,
84
+ })
85
+ .strict(),
86
+ outputSchema: BaseOutputSchema,
87
+ annotations: {
88
+ readOnlyHint: true,
89
+ destructiveHint: false,
90
+ idempotentHint: true,
91
+ openWorldHint: true,
92
+ },
93
+ }, async ({ item_id, language, fields, response_format }) => {
94
+ const data = await clients.xivapi.getItemById(item_id, {
95
+ language,
96
+ fields,
97
+ });
98
+ return buildToolResponse({
99
+ title: "Item Details",
100
+ responseFormat: response_format,
101
+ data,
102
+ meta: {
103
+ source: "xivapi",
104
+ endpoint: "/sheet/Item/{row}",
105
+ item_id,
106
+ ...(language ? { language } : {}),
107
+ },
108
+ });
109
+ });
110
+ }
@@ -0,0 +1,205 @@
1
+ import { z } from "zod";
2
+ import { BaseOutputSchema, ResponseFormatSchema } from "../schemas/common.js";
3
+ import { buildToolResponse } from "../utils/format.js";
4
+ const ItemIdsSchema = z
5
+ .array(z.number().int().min(1))
6
+ .min(1)
7
+ .max(100)
8
+ .describe("Item IDs (max 100). Example: [5333, 5334]");
9
+ const WorldDcRegionSchema = z
10
+ .string()
11
+ .min(1)
12
+ .describe('World, data center, or region. Example: "Primal" or "North-America".');
13
+ function extractNumberArray(value, key) {
14
+ if (!value || typeof value !== "object")
15
+ return undefined;
16
+ const record = value;
17
+ const arr = record[key];
18
+ return Array.isArray(arr) ? arr : undefined;
19
+ }
20
+ export function registerMarketTools(server, clients) {
21
+ server.registerTool("universalis_get_aggregated_prices", {
22
+ title: "Universalis Aggregated Prices",
23
+ description: "Fetch aggregated market board data for up to 100 item IDs on a world, data center, or region. Uses cached values only.",
24
+ inputSchema: z
25
+ .object({
26
+ world_dc_region: WorldDcRegionSchema,
27
+ item_ids: ItemIdsSchema,
28
+ response_format: ResponseFormatSchema,
29
+ })
30
+ .strict(),
31
+ outputSchema: BaseOutputSchema,
32
+ annotations: {
33
+ readOnlyHint: true,
34
+ destructiveHint: false,
35
+ idempotentHint: true,
36
+ openWorldHint: true,
37
+ },
38
+ }, async ({ world_dc_region, item_ids, response_format }) => {
39
+ const data = await clients.universalis.getAggregatedMarketData(world_dc_region, item_ids);
40
+ const failedItems = extractNumberArray(data, "failedItems");
41
+ return buildToolResponse({
42
+ title: "Aggregated Prices",
43
+ responseFormat: response_format,
44
+ data,
45
+ meta: {
46
+ source: "universalis",
47
+ endpoint: "/aggregated/{worldDcRegion}/{itemIds}",
48
+ world_dc_region,
49
+ item_ids,
50
+ ...(failedItems ? { failed_items: failedItems } : {}),
51
+ },
52
+ });
53
+ });
54
+ server.registerTool("universalis_get_current_listings", {
55
+ title: "Universalis Current Listings",
56
+ description: "Fetch current market board listings and recent history for up to 100 item IDs on a world, data center, or region.",
57
+ inputSchema: z
58
+ .object({
59
+ world_dc_region: WorldDcRegionSchema,
60
+ item_ids: ItemIdsSchema,
61
+ listings: z
62
+ .number()
63
+ .int()
64
+ .min(0)
65
+ .optional()
66
+ .describe("Listings to return per item (default: all)."),
67
+ entries: z
68
+ .number()
69
+ .int()
70
+ .min(0)
71
+ .optional()
72
+ .describe("History entries to return per item (default: 5)."),
73
+ hq: z
74
+ .boolean()
75
+ .optional()
76
+ .describe("Filter for HQ listings and entries."),
77
+ stats_within_ms: z
78
+ .number()
79
+ .int()
80
+ .min(0)
81
+ .optional()
82
+ .describe("Time window for stats in milliseconds (default: 7 days)."),
83
+ entries_within_seconds: z
84
+ .number()
85
+ .int()
86
+ .min(0)
87
+ .optional()
88
+ .describe("Time window for entries in seconds."),
89
+ fields: z
90
+ .string()
91
+ .optional()
92
+ .describe("Comma-separated field list. For multi-item queries use items.<field> (e.g. items.listings.pricePerUnit)."),
93
+ response_format: ResponseFormatSchema,
94
+ })
95
+ .strict(),
96
+ outputSchema: BaseOutputSchema,
97
+ annotations: {
98
+ readOnlyHint: true,
99
+ destructiveHint: false,
100
+ idempotentHint: true,
101
+ openWorldHint: true,
102
+ },
103
+ }, async ({ world_dc_region, item_ids, listings, entries, hq, stats_within_ms, entries_within_seconds, fields, response_format, }) => {
104
+ const query = {
105
+ listings,
106
+ entries,
107
+ hq,
108
+ statsWithin: stats_within_ms,
109
+ entriesWithin: entries_within_seconds,
110
+ fields,
111
+ };
112
+ const data = await clients.universalis.getCurrentMarketData(world_dc_region, item_ids, query);
113
+ const unresolvedItems = extractNumberArray(data, "unresolvedItems");
114
+ return buildToolResponse({
115
+ title: "Current Listings",
116
+ responseFormat: response_format,
117
+ data,
118
+ meta: {
119
+ source: "universalis",
120
+ endpoint: "/{worldDcRegion}/{itemIds}",
121
+ world_dc_region,
122
+ item_ids,
123
+ ...(unresolvedItems ? { unresolved_items: unresolvedItems } : {}),
124
+ },
125
+ });
126
+ });
127
+ server.registerTool("universalis_get_sales_history", {
128
+ title: "Universalis Sales History",
129
+ description: "Fetch market board sales history for up to 100 item IDs on a world or data center.",
130
+ inputSchema: z
131
+ .object({
132
+ world_dc_region: WorldDcRegionSchema,
133
+ item_ids: ItemIdsSchema,
134
+ entries_to_return: z
135
+ .number()
136
+ .int()
137
+ .min(1)
138
+ .max(99999)
139
+ .optional()
140
+ .describe("Entries to return per item (default: 1800, max: 99999)."),
141
+ stats_within_ms: z
142
+ .number()
143
+ .int()
144
+ .min(0)
145
+ .optional()
146
+ .describe("Time window for stats in milliseconds (default: 7 days)."),
147
+ entries_within_seconds: z
148
+ .number()
149
+ .int()
150
+ .min(0)
151
+ .optional()
152
+ .describe("Time window for entries in seconds (default: 7 days)."),
153
+ entries_until: z
154
+ .number()
155
+ .int()
156
+ .min(0)
157
+ .optional()
158
+ .describe("UNIX timestamp (seconds) to include entries up to."),
159
+ min_sale_price: z
160
+ .number()
161
+ .int()
162
+ .min(0)
163
+ .optional()
164
+ .describe("Minimum sale price per unit."),
165
+ max_sale_price: z
166
+ .number()
167
+ .int()
168
+ .min(0)
169
+ .optional()
170
+ .describe("Maximum sale price per unit."),
171
+ response_format: ResponseFormatSchema,
172
+ })
173
+ .strict(),
174
+ outputSchema: BaseOutputSchema,
175
+ annotations: {
176
+ readOnlyHint: true,
177
+ destructiveHint: false,
178
+ idempotentHint: true,
179
+ openWorldHint: true,
180
+ },
181
+ }, async ({ world_dc_region, item_ids, entries_to_return, stats_within_ms, entries_within_seconds, entries_until, min_sale_price, max_sale_price, response_format, }) => {
182
+ const query = {
183
+ entriesToReturn: entries_to_return,
184
+ statsWithin: stats_within_ms,
185
+ entriesWithin: entries_within_seconds,
186
+ entriesUntil: entries_until,
187
+ minSalePrice: min_sale_price,
188
+ maxSalePrice: max_sale_price,
189
+ };
190
+ const data = await clients.universalis.getSalesHistory(world_dc_region, item_ids, query);
191
+ const unresolvedItems = extractNumberArray(data, "unresolvedItems");
192
+ return buildToolResponse({
193
+ title: "Sales History",
194
+ responseFormat: response_format,
195
+ data,
196
+ meta: {
197
+ source: "universalis",
198
+ endpoint: "/history/{worldDcRegion}/{itemIds}",
199
+ world_dc_region,
200
+ item_ids,
201
+ ...(unresolvedItems ? { unresolved_items: unresolvedItems } : {}),
202
+ },
203
+ });
204
+ });
205
+ }
@@ -0,0 +1,178 @@
1
+ import { z } from "zod";
2
+ import { BaseOutputSchema, ResponseFormatSchema } from "../schemas/common.js";
3
+ import { buildToolResponse } from "../utils/format.js";
4
+ import { paginateArray } from "../utils/pagination.js";
5
+ import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from "../constants.js";
6
+ const PaginationSchema = z
7
+ .object({
8
+ limit: z
9
+ .number()
10
+ .int()
11
+ .min(1)
12
+ .max(MAX_PAGE_LIMIT)
13
+ .default(DEFAULT_PAGE_LIMIT)
14
+ .describe("Maximum results to return (default: 20, max: 100)."),
15
+ offset: z.number().int().min(0).default(0).describe("Results offset for pagination."),
16
+ })
17
+ .strict();
18
+ export function registerReferenceTools(server, clients) {
19
+ server.registerTool("universalis_list_worlds", {
20
+ title: "List Universalis Worlds",
21
+ description: "List all worlds supported by Universalis, with optional pagination.",
22
+ inputSchema: PaginationSchema.extend({
23
+ response_format: ResponseFormatSchema,
24
+ }).strict(),
25
+ outputSchema: BaseOutputSchema,
26
+ annotations: {
27
+ readOnlyHint: true,
28
+ destructiveHint: false,
29
+ idempotentHint: true,
30
+ openWorldHint: true,
31
+ },
32
+ }, async ({ limit, offset, response_format }) => {
33
+ const worlds = await clients.universalis.listWorlds();
34
+ const page = paginateArray(worlds, offset, limit);
35
+ return buildToolResponse({
36
+ title: "Universalis Worlds",
37
+ responseFormat: response_format,
38
+ data: page,
39
+ meta: { source: "universalis", endpoint: "/worlds" },
40
+ summaryLines: [
41
+ `Total worlds: ${page.total}`,
42
+ `Showing ${page.count} starting at offset ${page.offset}`,
43
+ ],
44
+ });
45
+ });
46
+ server.registerTool("universalis_list_data_centers", {
47
+ title: "List Universalis Data Centers",
48
+ description: "List all data centers supported by Universalis, with optional pagination.",
49
+ inputSchema: PaginationSchema.extend({
50
+ response_format: ResponseFormatSchema,
51
+ }).strict(),
52
+ outputSchema: BaseOutputSchema,
53
+ annotations: {
54
+ readOnlyHint: true,
55
+ destructiveHint: false,
56
+ idempotentHint: true,
57
+ openWorldHint: true,
58
+ },
59
+ }, async ({ limit, offset, response_format }) => {
60
+ const dataCenters = await clients.universalis.listDataCenters();
61
+ const page = paginateArray(dataCenters, offset, limit);
62
+ return buildToolResponse({
63
+ title: "Universalis Data Centers",
64
+ responseFormat: response_format,
65
+ data: page,
66
+ meta: { source: "universalis", endpoint: "/data-centers" },
67
+ summaryLines: [
68
+ `Total data centers: ${page.total}`,
69
+ `Showing ${page.count} starting at offset ${page.offset}`,
70
+ ],
71
+ });
72
+ });
73
+ server.registerTool("universalis_get_tax_rates", {
74
+ title: "Universalis Tax Rates",
75
+ description: "Retrieve current market tax rates for a world.",
76
+ inputSchema: z
77
+ .object({
78
+ world: z
79
+ .string()
80
+ .min(1)
81
+ .describe('World name or ID. Example: "Ragnarok" or "74".'),
82
+ response_format: ResponseFormatSchema,
83
+ })
84
+ .strict(),
85
+ outputSchema: BaseOutputSchema,
86
+ annotations: {
87
+ readOnlyHint: true,
88
+ destructiveHint: false,
89
+ idempotentHint: true,
90
+ openWorldHint: true,
91
+ },
92
+ }, async ({ world, response_format }) => {
93
+ const data = await clients.universalis.getTaxRates(world);
94
+ return buildToolResponse({
95
+ title: "Tax Rates",
96
+ responseFormat: response_format,
97
+ data,
98
+ meta: { source: "universalis", endpoint: "/tax-rates", world },
99
+ });
100
+ });
101
+ server.registerTool("universalis_list_marketable_items", {
102
+ title: "List Marketable Items",
103
+ description: "Return marketable item IDs from Universalis, with optional pagination.",
104
+ inputSchema: PaginationSchema.extend({
105
+ response_format: ResponseFormatSchema,
106
+ }).strict(),
107
+ outputSchema: BaseOutputSchema,
108
+ annotations: {
109
+ readOnlyHint: true,
110
+ destructiveHint: false,
111
+ idempotentHint: true,
112
+ openWorldHint: true,
113
+ },
114
+ }, async ({ limit, offset, response_format }) => {
115
+ const items = await clients.universalis.listMarketableItems();
116
+ const page = paginateArray(items, offset, limit);
117
+ return buildToolResponse({
118
+ title: "Marketable Items",
119
+ responseFormat: response_format,
120
+ data: page,
121
+ meta: { source: "universalis", endpoint: "/marketable" },
122
+ summaryLines: [
123
+ `Total IDs: ${page.total}`,
124
+ `Showing ${page.count} starting at offset ${page.offset}`,
125
+ ],
126
+ });
127
+ });
128
+ server.registerTool("universalis_get_list", {
129
+ title: "Get Universalis List",
130
+ description: "Retrieve a user list by ID from Universalis.",
131
+ inputSchema: z
132
+ .object({
133
+ list_id: z.string().min(1).describe("List ID from Universalis."),
134
+ response_format: ResponseFormatSchema,
135
+ })
136
+ .strict(),
137
+ outputSchema: BaseOutputSchema,
138
+ annotations: {
139
+ readOnlyHint: true,
140
+ destructiveHint: false,
141
+ idempotentHint: true,
142
+ openWorldHint: true,
143
+ },
144
+ }, async ({ list_id, response_format }) => {
145
+ const data = await clients.universalis.getList(list_id);
146
+ return buildToolResponse({
147
+ title: "Universalis List",
148
+ responseFormat: response_format,
149
+ data,
150
+ meta: { source: "universalis", endpoint: "/lists/{listId}", list_id },
151
+ });
152
+ });
153
+ server.registerTool("universalis_get_content", {
154
+ title: "Get Universalis Content",
155
+ description: "Retrieve content metadata by content ID (best-effort, endpoint may be inconsistent).",
156
+ inputSchema: z
157
+ .object({
158
+ content_id: z.string().min(1).describe("Content ID from Universalis."),
159
+ response_format: ResponseFormatSchema,
160
+ })
161
+ .strict(),
162
+ outputSchema: BaseOutputSchema,
163
+ annotations: {
164
+ readOnlyHint: true,
165
+ destructiveHint: false,
166
+ idempotentHint: true,
167
+ openWorldHint: true,
168
+ },
169
+ }, async ({ content_id, response_format }) => {
170
+ const data = await clients.universalis.getContent(content_id);
171
+ return buildToolResponse({
172
+ title: "Universalis Content",
173
+ responseFormat: response_format,
174
+ data,
175
+ meta: { source: "universalis", endpoint: "/extra/content/{contentId}", content_id },
176
+ });
177
+ });
178
+ }
@@ -0,0 +1,190 @@
1
+ import { z } from "zod";
2
+ import { BaseOutputSchema, ResponseFormatSchema } from "../schemas/common.js";
3
+ import { buildToolResponse } from "../utils/format.js";
4
+ const StatsScopeBase = z
5
+ .object({
6
+ world: z.string().min(1).optional().describe('World name or ID. Example: "Gilgamesh".'),
7
+ dc_name: z.string().min(1).optional().describe('Data center name. Example: "Aether".'),
8
+ })
9
+ .strict();
10
+ const EntriesSchema = z
11
+ .number()
12
+ .int()
13
+ .min(1)
14
+ .max(200)
15
+ .default(50)
16
+ .describe("Number of entries to return (default: 50, max: 200).");
17
+ export function registerStatsTools(server, clients) {
18
+ server.registerTool("universalis_get_most_recent_updates", {
19
+ title: "Most Recently Updated Items",
20
+ description: "Get the most recently updated items for a world or data center.",
21
+ inputSchema: StatsScopeBase.extend({
22
+ entries: EntriesSchema.optional(),
23
+ response_format: ResponseFormatSchema,
24
+ })
25
+ .strict()
26
+ .superRefine((value, ctx) => {
27
+ if (!value.world && !value.dc_name) {
28
+ ctx.addIssue({
29
+ code: z.ZodIssueCode.custom,
30
+ message: "Provide either world or dc_name.",
31
+ });
32
+ }
33
+ }),
34
+ outputSchema: BaseOutputSchema,
35
+ annotations: {
36
+ readOnlyHint: true,
37
+ destructiveHint: false,
38
+ idempotentHint: true,
39
+ openWorldHint: true,
40
+ },
41
+ }, async ({ world, dc_name, entries, response_format }) => {
42
+ const data = await clients.universalis.getMostRecentlyUpdated(world, dc_name, entries);
43
+ return buildToolResponse({
44
+ title: "Most Recently Updated Items",
45
+ responseFormat: response_format,
46
+ data,
47
+ meta: {
48
+ source: "universalis",
49
+ endpoint: "/extra/stats/most-recently-updated",
50
+ ...(world ? { world } : {}),
51
+ ...(dc_name ? { dc_name } : {}),
52
+ ...(entries ? { entries } : {}),
53
+ },
54
+ });
55
+ });
56
+ server.registerTool("universalis_get_least_recent_updates", {
57
+ title: "Least Recently Updated Items",
58
+ description: "Get the least recently updated items for a world or data center.",
59
+ inputSchema: StatsScopeBase.extend({
60
+ entries: EntriesSchema.optional(),
61
+ response_format: ResponseFormatSchema,
62
+ })
63
+ .strict()
64
+ .superRefine((value, ctx) => {
65
+ if (!value.world && !value.dc_name) {
66
+ ctx.addIssue({
67
+ code: z.ZodIssueCode.custom,
68
+ message: "Provide either world or dc_name.",
69
+ });
70
+ }
71
+ }),
72
+ outputSchema: BaseOutputSchema,
73
+ annotations: {
74
+ readOnlyHint: true,
75
+ destructiveHint: false,
76
+ idempotentHint: true,
77
+ openWorldHint: true,
78
+ },
79
+ }, async ({ world, dc_name, entries, response_format }) => {
80
+ const data = await clients.universalis.getLeastRecentlyUpdated(world, dc_name, entries);
81
+ return buildToolResponse({
82
+ title: "Least Recently Updated Items",
83
+ responseFormat: response_format,
84
+ data,
85
+ meta: {
86
+ source: "universalis",
87
+ endpoint: "/extra/stats/least-recently-updated",
88
+ ...(world ? { world } : {}),
89
+ ...(dc_name ? { dc_name } : {}),
90
+ ...(entries ? { entries } : {}),
91
+ },
92
+ });
93
+ });
94
+ server.registerTool("universalis_get_recent_updates", {
95
+ title: "Recently Updated Items (Legacy)",
96
+ description: "Get a legacy list of recently updated items (no world/DC info).",
97
+ inputSchema: z
98
+ .object({
99
+ response_format: ResponseFormatSchema,
100
+ })
101
+ .strict(),
102
+ outputSchema: BaseOutputSchema,
103
+ annotations: {
104
+ readOnlyHint: true,
105
+ destructiveHint: false,
106
+ idempotentHint: true,
107
+ openWorldHint: true,
108
+ },
109
+ }, async ({ response_format }) => {
110
+ const data = await clients.universalis.getRecentlyUpdated();
111
+ return buildToolResponse({
112
+ title: "Recently Updated Items",
113
+ responseFormat: response_format,
114
+ data,
115
+ meta: { source: "universalis", endpoint: "/extra/stats/recently-updated" },
116
+ });
117
+ });
118
+ server.registerTool("universalis_get_upload_counts_by_source", {
119
+ title: "Upload Counts by Source",
120
+ description: "Return total upload counts for each client application.",
121
+ inputSchema: z
122
+ .object({
123
+ response_format: ResponseFormatSchema,
124
+ })
125
+ .strict(),
126
+ outputSchema: BaseOutputSchema,
127
+ annotations: {
128
+ readOnlyHint: true,
129
+ destructiveHint: false,
130
+ idempotentHint: true,
131
+ openWorldHint: true,
132
+ },
133
+ }, async ({ response_format }) => {
134
+ const data = await clients.universalis.getUploaderUploadCounts();
135
+ return buildToolResponse({
136
+ title: "Upload Counts by Source",
137
+ responseFormat: response_format,
138
+ data,
139
+ meta: { source: "universalis", endpoint: "/extra/stats/uploader-upload-counts" },
140
+ });
141
+ });
142
+ server.registerTool("universalis_get_upload_counts_by_world", {
143
+ title: "Upload Counts by World",
144
+ description: "Return upload counts and proportions for each world.",
145
+ inputSchema: z
146
+ .object({
147
+ response_format: ResponseFormatSchema,
148
+ })
149
+ .strict(),
150
+ outputSchema: BaseOutputSchema,
151
+ annotations: {
152
+ readOnlyHint: true,
153
+ destructiveHint: false,
154
+ idempotentHint: true,
155
+ openWorldHint: true,
156
+ },
157
+ }, async ({ response_format }) => {
158
+ const data = await clients.universalis.getWorldUploadCounts();
159
+ return buildToolResponse({
160
+ title: "Upload Counts by World",
161
+ responseFormat: response_format,
162
+ data,
163
+ meta: { source: "universalis", endpoint: "/extra/stats/world-upload-counts" },
164
+ });
165
+ });
166
+ server.registerTool("universalis_get_upload_history", {
167
+ title: "Upload History",
168
+ description: "Return the number of uploads per day over the past 30 days.",
169
+ inputSchema: z
170
+ .object({
171
+ response_format: ResponseFormatSchema,
172
+ })
173
+ .strict(),
174
+ outputSchema: BaseOutputSchema,
175
+ annotations: {
176
+ readOnlyHint: true,
177
+ destructiveHint: false,
178
+ idempotentHint: true,
179
+ openWorldHint: true,
180
+ },
181
+ }, async ({ response_format }) => {
182
+ const data = await clients.universalis.getUploadHistory();
183
+ return buildToolResponse({
184
+ title: "Upload History",
185
+ responseFormat: response_format,
186
+ data,
187
+ meta: { source: "universalis", endpoint: "/extra/stats/upload-history" },
188
+ });
189
+ });
190
+ }
@@ -0,0 +1,38 @@
1
+ import { CHARACTER_LIMIT } from "../constants.js";
2
+ import { ResponseFormat } from "../schemas/common.js";
3
+ export function buildToolResponse({ title, responseFormat, data, meta, summaryLines, }) {
4
+ let metaOut = meta ? { ...meta } : undefined;
5
+ let output = {
6
+ data,
7
+ ...(metaOut ? { meta: metaOut } : {}),
8
+ };
9
+ let text = renderText(responseFormat, title, summaryLines, output);
10
+ let textTruncated = false;
11
+ if (text.length > CHARACTER_LIMIT) {
12
+ textTruncated = true;
13
+ metaOut = { ...(metaOut ?? {}), text_truncated: true };
14
+ output = { data, meta: metaOut };
15
+ text = renderText(responseFormat, title, summaryLines, output);
16
+ if (text.length > CHARACTER_LIMIT) {
17
+ text = `${text.slice(0, CHARACTER_LIMIT - 120)}\n\n... truncated ...`;
18
+ }
19
+ }
20
+ return {
21
+ content: [{ type: "text", text }],
22
+ structuredContent: {
23
+ data,
24
+ ...(metaOut ? { meta: metaOut } : {}),
25
+ },
26
+ };
27
+ }
28
+ function renderText(responseFormat, title, summaryLines, output) {
29
+ if (responseFormat === ResponseFormat.JSON) {
30
+ return JSON.stringify(output, null, 2);
31
+ }
32
+ const lines = [`# ${title}`];
33
+ if (summaryLines && summaryLines.length > 0) {
34
+ lines.push("", ...summaryLines.map((line) => `- ${line}`));
35
+ }
36
+ lines.push("", "```json", JSON.stringify(output, null, 2), "```");
37
+ return lines.join("\n");
38
+ }
@@ -0,0 +1,16 @@
1
+ export function paginateArray(items, offset, limit) {
2
+ const total = items.length;
3
+ const safeOffset = Math.max(0, Math.min(offset, total));
4
+ const safeLimit = Math.max(0, limit);
5
+ const end = Math.min(safeOffset + safeLimit, total);
6
+ const pageItems = items.slice(safeOffset, end);
7
+ const hasMore = end < total;
8
+ return {
9
+ total,
10
+ count: pageItems.length,
11
+ offset: safeOffset,
12
+ items: pageItems,
13
+ has_more: hasMore,
14
+ ...(hasMore ? { next_offset: end } : {}),
15
+ };
16
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "universalis-mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Universalis and XIVAPI",
5
+ "author": "Jakub Mucha <jakub.mucha@icloud.com>",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/drptbl/universalis-mcp-server.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/drptbl/universalis-mcp-server/issues"
12
+ },
13
+ "homepage": "https://github.com/drptbl/universalis-mcp-server#readme",
14
+ "type": "module",
15
+ "main": "dist/index.js",
16
+ "bin": {
17
+ "universalis-mcp-server": "./dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "keywords": [
23
+ "mcp",
24
+ "universalis",
25
+ "ffxiv"
26
+ ],
27
+ "license": "ISC",
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "1.25.2",
30
+ "bottleneck": "2.19.5",
31
+ "lru-cache": "11.2.4",
32
+ "zod": "4.3.5"
33
+ },
34
+ "devDependencies": {
35
+ "@biomejs/biome": "2.3.11",
36
+ "@types/node": "25.0.6",
37
+ "typescript": "5.9.3"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc && chmod 755 ./dist/index.js",
41
+ "lint": "biome check .",
42
+ "format": "biome format --write ."
43
+ }
44
+ }