osrs-companion 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +76 -0
  3. package/dist/index.js +508 -0
  4. package/package.json +35 -0
package/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, isaac
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # osrs-companion
2
+
3
+ An MCP server for Old School RuneScape that gives AI assistants access to
4
+ wiki search, Grand Exchange prices, and your synced player data — all running
5
+ locally on your machine.
6
+
7
+ ## Features
8
+
9
+ - **Wiki Search** — Search the OSRS Wiki for any article
10
+ - **Page Summaries** — Get introductory summaries of wiki pages
11
+ - **GE Prices** — Look up current Grand Exchange buy/sell prices
12
+ - **WikiSync Player Data** — Fetch player data via the WikiSync plugin
13
+ - **Local Player Sync** — Read detailed player data saved by the companion RuneLite plugin (bank, skills, quests, equipment, inventory, diaries, combat achievements)
14
+
15
+ ## Prerequisites
16
+
17
+ For the local player sync tools, install the **OSRS MCP Companion** RuneLite
18
+ plugin. Wiki search, summaries, and GE prices work without it.
19
+
20
+ ## Installation
21
+
22
+ ### Claude Code / Claude Desktop
23
+
24
+ Add to your MCP configuration:
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "osrs-companion": {
30
+ "command": "npx",
31
+ "args": ["-y", "osrs-companion"]
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ ### Manual
38
+
39
+ ```bash
40
+ npx -y osrs-companion
41
+ ```
42
+
43
+ ## Available Tools
44
+
45
+ ### Wiki Tools
46
+ | Tool | Description |
47
+ |------|-------------|
48
+ | `search` | Search the OSRS Wiki for articles |
49
+ | `summary` | Get the intro summary of a wiki page |
50
+ | `price` | Look up Grand Exchange prices |
51
+ | `player` | Fetch player data via WikiSync |
52
+
53
+ ### Player Sync Tools (requires RuneLite plugin)
54
+ | Tool | Description |
55
+ |------|-------------|
56
+ | `list_synced_players` | List players with synced data |
57
+ | `get_my_profile` | Full player summary |
58
+ | `get_my_bank` | Search bank contents |
59
+ | `get_my_stats` | Skill levels and XP |
60
+ | `get_my_quests` | Quest completion status |
61
+ | `get_my_equipment` | Currently equipped items |
62
+ | `get_my_inventory` | Current inventory |
63
+ | `get_my_diaries` | Achievement diary progress |
64
+ | `get_my_combat_achievements` | Combat achievement status |
65
+
66
+ ## How It Works
67
+
68
+ The MCP server runs locally via stdio transport. Wiki and price tools
69
+ fetch from public OSRS APIs. Player sync tools read JSON files from
70
+ `~/.runelite/mcp-sync/` that are written by the companion RuneLite plugin.
71
+
72
+ No data is stored in the cloud. No API keys required.
73
+
74
+ ## License
75
+
76
+ BSD 2-Clause "Simplified" License. See [LICENSE](LICENSE).
package/dist/index.js ADDED
@@ -0,0 +1,508 @@
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 { z } from "zod";
5
+ import { readdir, readFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ // ── Constants ──────────────────────────────────────────────────────────
9
+ const WIKI_API = "https://oldschool.runescape.wiki/api.php";
10
+ const PRICES_API = "https://prices.runescape.wiki/api/v1/osrs";
11
+ const USER_AGENT = "osrs-mcp-companion/1.0 (Node.js; github.com/isaachansen/osrs-mcp-companion)";
12
+ const SYNC_DIR = join(homedir(), ".runelite", "mcp-sync");
13
+ // ── Wiki / Price Helpers ────────────────────────────────────────────────
14
+ function pageUrl(title) {
15
+ return `https://oldschool.runescape.wiki/w/${encodeURIComponent(title.replace(/ /g, "_"))}`;
16
+ }
17
+ function stripHtml(text) {
18
+ return text.replace(/<[^>]+>/g, "").replace(/&quot;/g, '"').replace(/&amp;/g, "&");
19
+ }
20
+ async function wikiFetch(params) {
21
+ const url = `${WIKI_API}?${new URLSearchParams({ format: "json", ...params })}`;
22
+ const res = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
23
+ if (!res.ok)
24
+ throw new Error(`Wiki API returned ${res.status}`);
25
+ return res.json();
26
+ }
27
+ async function pricesFetch(path) {
28
+ const res = await fetch(`${PRICES_API}/${path}`, {
29
+ headers: { "User-Agent": USER_AGENT },
30
+ });
31
+ if (!res.ok)
32
+ throw new Error(`Prices API returned ${res.status}`);
33
+ return res.json();
34
+ }
35
+ // ── Item Mapping Cache ──────────────────────────────────────────────────
36
+ let itemMappingCache = null;
37
+ let itemMappingExpiry = 0;
38
+ const CACHE_TTL = 1000 * 60 * 60; // 1 hour
39
+ async function getItemMapping() {
40
+ if (itemMappingCache && Date.now() < itemMappingExpiry) {
41
+ return itemMappingCache;
42
+ }
43
+ const data = await pricesFetch("mapping");
44
+ const mapping = {};
45
+ if (Array.isArray(data)) {
46
+ for (const item of data) {
47
+ mapping[String(item.id)] = item.name;
48
+ }
49
+ }
50
+ itemMappingCache = mapping;
51
+ itemMappingExpiry = Date.now() + CACHE_TTL;
52
+ return mapping;
53
+ }
54
+ async function findItemId(name) {
55
+ const mapping = await getItemMapping();
56
+ const lower = name.toLowerCase();
57
+ for (const [id, itemName] of Object.entries(mapping)) {
58
+ if (itemName.toLowerCase() === lower)
59
+ return id;
60
+ }
61
+ for (const [id, itemName] of Object.entries(mapping)) {
62
+ if (itemName.toLowerCase().includes(lower))
63
+ return id;
64
+ }
65
+ return null;
66
+ }
67
+ function formatTimeAgo(unixSeconds) {
68
+ const diff = Math.floor(Date.now() / 1000) - unixSeconds;
69
+ if (diff < 60)
70
+ return "just now";
71
+ if (diff < 3600)
72
+ return `${Math.floor(diff / 60)}m ago`;
73
+ if (diff < 86400)
74
+ return `${Math.floor(diff / 3600)}h ago`;
75
+ return `${Math.floor(diff / 86400)}d ago`;
76
+ }
77
+ // ── Player Sync Helpers ─────────────────────────────────────────────────
78
+ async function getPlayerSyncData(username) {
79
+ const filename = username.toLowerCase().replace(/[^a-z0-9_-]/g, "_") + ".json";
80
+ const filepath = join(SYNC_DIR, filename);
81
+ try {
82
+ const raw = await readFile(filepath, "utf-8");
83
+ return JSON.parse(raw);
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ async function listSyncedPlayers() {
90
+ try {
91
+ const files = await readdir(SYNC_DIR);
92
+ return files
93
+ .filter((f) => f.endsWith(".json"))
94
+ .map((f) => f.replace(/\.json$/, ""));
95
+ }
96
+ catch {
97
+ return [];
98
+ }
99
+ }
100
+ // ── WikiSync Player Cache ───────────────────────────────────────────────
101
+ const playerDataCache = {};
102
+ async function fetchWikiSyncPlayer(username, forceRefresh = false) {
103
+ const now = Date.now();
104
+ const cache = playerDataCache[username];
105
+ if (cache && !forceRefresh && now - cache.fetchedAt < 3600_000) {
106
+ return { data: cache.data };
107
+ }
108
+ const url = `https://sync.runescape.wiki/runelite/player/${encodeURIComponent(username)}/STANDARD`;
109
+ try {
110
+ const resp = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
111
+ if (!resp.ok) {
112
+ return { data: null, message: `WikiSync API returned ${resp.status}` };
113
+ }
114
+ const data = (await resp.json());
115
+ if (!data || Object.keys(data).length === 0) {
116
+ return {
117
+ data: null,
118
+ message: "No player data found. Ensure the username is correct and you have the WikiSync plugin installed in RuneLite.",
119
+ };
120
+ }
121
+ playerDataCache[username] = { data, fetchedAt: now };
122
+ return { data };
123
+ }
124
+ catch (err) {
125
+ return { data: null, message: `Error: ${err instanceof Error ? err.message : "Unknown error"}` };
126
+ }
127
+ }
128
+ // ── MCP Server ──────────────────────────────────────────────────────────
129
+ const server = new McpServer({
130
+ name: "osrs-mcp-companion",
131
+ version: "1.0.0",
132
+ });
133
+ // ── Wiki Tools ──────────────────────────────────────────────────────────
134
+ server.tool("search", "Search the Old School RuneScape Wiki for articles matching a query", {
135
+ query: z.string().describe("Search query (e.g. 'dragon scimitar', 'Zulrah')"),
136
+ limit: z.number().min(1).max(50).default(10).describe("Max results (1-50)"),
137
+ }, async ({ query, limit }) => {
138
+ const data = await wikiFetch({
139
+ action: "query",
140
+ list: "search",
141
+ srsearch: query,
142
+ srlimit: String(limit),
143
+ });
144
+ const results = data.query?.search ?? [];
145
+ if (!results.length) {
146
+ return { content: [{ type: "text", text: `No results found for "${query}"` }] };
147
+ }
148
+ const lines = results.map((item, i) => {
149
+ const snippet = stripHtml(item.snippet);
150
+ return `${i + 1}. **${item.title}**\n ${snippet}\n ${pageUrl(item.title)}`;
151
+ });
152
+ return {
153
+ content: [{ type: "text", text: `Found ${results.length} results:\n\n${lines.join("\n\n")}` }],
154
+ };
155
+ });
156
+ server.tool("summary", "Get the introductory summary of an OSRS Wiki page", {
157
+ title: z.string().describe("Exact page title (e.g. 'Abyssal whip', 'Farming')"),
158
+ }, async ({ title }) => {
159
+ const data = await wikiFetch({
160
+ action: "query",
161
+ prop: "extracts",
162
+ exintro: "1",
163
+ explaintext: "1",
164
+ formatversion: "2",
165
+ titles: title,
166
+ });
167
+ const page = data.query?.pages?.[0];
168
+ if (!page || page.missing) {
169
+ return { content: [{ type: "text", text: `Page not found: "${title}"` }] };
170
+ }
171
+ const extract = page.extract?.trim();
172
+ if (!extract) {
173
+ return { content: [{ type: "text", text: `No summary available for "${page.title}"` }] };
174
+ }
175
+ return {
176
+ content: [{ type: "text", text: `# ${page.title}\n\n${extract}\n\n${pageUrl(page.title)}` }],
177
+ };
178
+ });
179
+ server.tool("price", "Look up the current Grand Exchange price for an item", {
180
+ item: z.string().describe("Item name (e.g. 'Abyssal whip', 'Dragon bones')"),
181
+ }, async ({ item }) => {
182
+ const itemId = await findItemId(item);
183
+ if (!itemId) {
184
+ return { content: [{ type: "text", text: `Item not found: "${item}". Try the exact in-game name.` }] };
185
+ }
186
+ const data = await pricesFetch(`latest?id=${itemId}`);
187
+ const price = data.data?.[itemId];
188
+ if (!price) {
189
+ return { content: [{ type: "text", text: `No price data available for "${item}"` }] };
190
+ }
191
+ const mapping = await getItemMapping();
192
+ const name = mapping[itemId] ?? item;
193
+ const lines = [`# ${name} — Grand Exchange Price`];
194
+ if (price.high != null) {
195
+ const ago = price.highTime ? ` (${formatTimeAgo(price.highTime)})` : "";
196
+ lines.push(`Buy (instant): ${price.high.toLocaleString()} gp${ago}`);
197
+ }
198
+ if (price.low != null) {
199
+ const ago = price.lowTime ? ` (${formatTimeAgo(price.lowTime)})` : "";
200
+ lines.push(`Sell (instant): ${price.low.toLocaleString()} gp${ago}`);
201
+ }
202
+ lines.push("", pageUrl(name));
203
+ return { content: [{ type: "text", text: lines.join("\n") }] };
204
+ });
205
+ server.tool("player", "Fetch RuneLite player data via the WikiSync plugin (requires RuneLite client)", {
206
+ username: z.string().describe("RuneLite username"),
207
+ forceRefresh: z.boolean().default(false).describe("Force refresh cached data"),
208
+ }, async ({ username, forceRefresh }) => {
209
+ if (!username.trim()) {
210
+ return { content: [{ type: "text", text: "Please provide a RuneLite username." }] };
211
+ }
212
+ const { data, message } = await fetchWikiSyncPlayer(username, forceRefresh);
213
+ if (!data) {
214
+ return { content: [{ type: "text", text: message ?? "No player data found." }] };
215
+ }
216
+ return {
217
+ content: [
218
+ {
219
+ type: "text",
220
+ text: `# ${username} — Player Data (via WikiSync)\n\n\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\``,
221
+ },
222
+ ],
223
+ };
224
+ });
225
+ // ── Player Sync Tools (local file reads) ────────────────────────────────
226
+ server.tool("list_synced_players", "List all players that have synced data from RuneLite. Use this first to find available usernames.", {}, async () => {
227
+ const players = await listSyncedPlayers();
228
+ if (players.length === 0) {
229
+ return {
230
+ content: [
231
+ {
232
+ type: "text",
233
+ text: `No synced players found. Make sure the OSRS MCP Companion RuneLite plugin is running and you've logged in.\n\nExpected data directory: ${SYNC_DIR}`,
234
+ },
235
+ ],
236
+ };
237
+ }
238
+ return { content: [{ type: "text", text: `Synced players: ${players.join(", ")}` }] };
239
+ });
240
+ server.tool("get_my_profile", "Get a full summary of synced player data including stats, quest count, bank size, and diary progress. Data is synced from RuneLite via the MCP Sync plugin.", {
241
+ username: z.string().describe("Player username"),
242
+ }, async ({ username }) => {
243
+ const data = await getPlayerSyncData(username);
244
+ if (!data) {
245
+ const players = await listSyncedPlayers();
246
+ const hint = players.length > 0 ? ` Available players: ${players.join(", ")}` : "";
247
+ return {
248
+ content: [
249
+ {
250
+ type: "text",
251
+ text: `No synced data found for "${username}".${hint}\n\nMake sure the OSRS MCP Companion RuneLite plugin is running and has saved data.`,
252
+ },
253
+ ],
254
+ };
255
+ }
256
+ const lines = [`# ${data.player.username} — Synced Profile`];
257
+ lines.push(`Combat Level: ${data.player.combatLevel} | World: ${data.player.world}`);
258
+ lines.push(`Last Updated: ${data.lastUpdated}`);
259
+ if (data.skills) {
260
+ const totalLevel = data.skills.OVERALL?.level ??
261
+ Object.values(data.skills).reduce((sum, s) => sum + s.level, 0);
262
+ lines.push(`\n## Skills — Total Level: ${totalLevel}`);
263
+ for (const [skill, entry] of Object.entries(data.skills)) {
264
+ if (skill === "OVERALL")
265
+ continue;
266
+ lines.push(` ${skill}: ${entry.level} (${entry.xp.toLocaleString()} xp)`);
267
+ }
268
+ }
269
+ if (data.quests) {
270
+ const finished = data.quests.filter((q) => q.state === "FINISHED").length;
271
+ const inProgress = data.quests.filter((q) => q.state === "IN_PROGRESS").length;
272
+ const notStarted = data.quests.filter((q) => q.state === "NOT_STARTED").length;
273
+ lines.push(`\n## Quests — ${finished} complete, ${inProgress} in progress, ${notStarted} not started`);
274
+ }
275
+ if (data.bank) {
276
+ lines.push(`\n## Bank — ${data.bank.totalItems} unique items across ${data.bank.tabs.length} tabs`);
277
+ }
278
+ if (data.achievementDiaries) {
279
+ lines.push("\n## Achievement Diaries");
280
+ for (const [region, diary] of Object.entries(data.achievementDiaries)) {
281
+ const tiers = [
282
+ diary.easy ? "Easy" : null,
283
+ diary.medium ? "Medium" : null,
284
+ diary.hard ? "Hard" : null,
285
+ diary.elite ? "Elite" : null,
286
+ ].filter(Boolean);
287
+ lines.push(` ${region}: ${tiers.length > 0 ? tiers.join(", ") : "None complete"}`);
288
+ }
289
+ }
290
+ if (data.combatAchievements) {
291
+ const ca = data.combatAchievements;
292
+ lines.push(`\n## Combat Achievements — ${ca.completedTasks.length} tasks complete`);
293
+ lines.push(` Easy: ${ca.easyComplete ? "Done" : "Incomplete"} | Medium: ${ca.mediumComplete ? "Done" : "Incomplete"} | Hard: ${ca.hardComplete ? "Done" : "Incomplete"} | Elite: ${ca.eliteComplete ? "Done" : "Incomplete"}`);
294
+ }
295
+ return { content: [{ type: "text", text: lines.join("\n") }] };
296
+ });
297
+ server.tool("get_my_bank", "Search and browse the player's synced bank contents. Supports filtering by item name, bank tab, and minimum quantity.", {
298
+ username: z.string().describe("Player username"),
299
+ search: z.string().optional().describe("Search term to filter items by name (case-insensitive)"),
300
+ tab: z.number().optional().describe("Bank tab number to filter (0-indexed)"),
301
+ minQuantity: z.number().optional().describe("Only show items with at least this quantity"),
302
+ }, async ({ username, search, tab, minQuantity }) => {
303
+ const data = await getPlayerSyncData(username);
304
+ if (!data) {
305
+ return { content: [{ type: "text", text: `No synced data found for "${username}".` }] };
306
+ }
307
+ if (!data.bank?.tabs) {
308
+ return {
309
+ content: [{ type: "text", text: `No bank data synced for "${username}". Open your bank in-game to sync.` }],
310
+ };
311
+ }
312
+ let allItems = data.bank.tabs.flatMap((t) => t.items.map((item) => ({ ...item, tab: t.tabIndex })));
313
+ if (search) {
314
+ const term = search.toLowerCase();
315
+ allItems = allItems.filter((item) => item.name.toLowerCase().includes(term));
316
+ }
317
+ if (tab !== undefined) {
318
+ allItems = allItems.filter((item) => item.tab === tab);
319
+ }
320
+ if (minQuantity !== undefined) {
321
+ allItems = allItems.filter((item) => item.quantity >= minQuantity);
322
+ }
323
+ if (allItems.length === 0) {
324
+ return { content: [{ type: "text", text: `No matching items found in ${username}'s bank.` }] };
325
+ }
326
+ const lines = [`# ${username}'s Bank — ${allItems.length} items found`];
327
+ for (const item of allItems) {
328
+ const qty = item.quantity > 1 ? ` x${item.quantity.toLocaleString()}` : "";
329
+ lines.push(` [Tab ${item.tab}] ${item.name}${qty} (ID: ${item.itemId})`);
330
+ }
331
+ return { content: [{ type: "text", text: lines.join("\n") }] };
332
+ });
333
+ server.tool("get_my_stats", "Get the player's synced skill levels and XP. Optionally filter to a specific skill.", {
334
+ username: z.string().describe("Player username"),
335
+ skill: z.string().optional().describe("Specific skill name (e.g. 'ATTACK', 'MINING'). Omit for all skills."),
336
+ }, async ({ username, skill }) => {
337
+ const data = await getPlayerSyncData(username);
338
+ if (!data) {
339
+ return { content: [{ type: "text", text: `No synced data found for "${username}".` }] };
340
+ }
341
+ if (!data.skills) {
342
+ return { content: [{ type: "text", text: `No skill data synced for "${username}".` }] };
343
+ }
344
+ if (skill) {
345
+ const key = skill.toUpperCase();
346
+ const entry = data.skills[key];
347
+ if (!entry) {
348
+ return {
349
+ content: [
350
+ { type: "text", text: `Skill "${skill}" not found. Available: ${Object.keys(data.skills).join(", ")}` },
351
+ ],
352
+ };
353
+ }
354
+ return {
355
+ content: [
356
+ { type: "text", text: `# ${username} — ${key}\nLevel: ${entry.level}\nXP: ${entry.xp.toLocaleString()}` },
357
+ ],
358
+ };
359
+ }
360
+ const lines = [`# ${username}'s Skills`];
361
+ for (const [name, entry] of Object.entries(data.skills)) {
362
+ lines.push(` ${name}: ${entry.level} (${entry.xp.toLocaleString()} xp)`);
363
+ }
364
+ return { content: [{ type: "text", text: lines.join("\n") }] };
365
+ });
366
+ server.tool("get_my_quests", "Get the player's synced quest completion status. Filter by state or search by name.", {
367
+ username: z.string().describe("Player username"),
368
+ state: z.enum(["NOT_STARTED", "IN_PROGRESS", "FINISHED"]).optional().describe("Filter by quest state"),
369
+ search: z.string().optional().describe("Search term to filter quests by name"),
370
+ }, async ({ username, state, search }) => {
371
+ const data = await getPlayerSyncData(username);
372
+ if (!data) {
373
+ return { content: [{ type: "text", text: `No synced data found for "${username}".` }] };
374
+ }
375
+ if (!data.quests) {
376
+ return { content: [{ type: "text", text: `No quest data synced for "${username}".` }] };
377
+ }
378
+ let quests = data.quests;
379
+ if (state) {
380
+ quests = quests.filter((q) => q.state === state);
381
+ }
382
+ if (search) {
383
+ const term = search.toLowerCase();
384
+ quests = quests.filter((q) => q.displayName.toLowerCase().includes(term));
385
+ }
386
+ if (quests.length === 0) {
387
+ return { content: [{ type: "text", text: "No matching quests found." }] };
388
+ }
389
+ const lines = [`# ${username}'s Quests — ${quests.length} results`];
390
+ for (const q of quests) {
391
+ const icon = q.state === "FINISHED"
392
+ ? "[Done]"
393
+ : q.state === "IN_PROGRESS"
394
+ ? "[In Progress]"
395
+ : "[Not Started]";
396
+ lines.push(` ${icon} ${q.displayName}`);
397
+ }
398
+ return { content: [{ type: "text", text: lines.join("\n") }] };
399
+ });
400
+ server.tool("get_my_equipment", "Get the player's currently equipped items (last synced state).", {
401
+ username: z.string().describe("Player username"),
402
+ }, async ({ username }) => {
403
+ const data = await getPlayerSyncData(username);
404
+ if (!data) {
405
+ return { content: [{ type: "text", text: `No synced data found for "${username}".` }] };
406
+ }
407
+ if (!data.equipment) {
408
+ return { content: [{ type: "text", text: `No equipment data synced for "${username}".` }] };
409
+ }
410
+ const lines = [`# ${username}'s Equipment`];
411
+ for (const [slot, item] of Object.entries(data.equipment)) {
412
+ if (item.itemId === -1) {
413
+ lines.push(` ${slot}: (empty)`);
414
+ }
415
+ else {
416
+ lines.push(` ${slot}: ${item.name} (ID: ${item.itemId})`);
417
+ }
418
+ }
419
+ return { content: [{ type: "text", text: lines.join("\n") }] };
420
+ });
421
+ server.tool("get_my_inventory", "Get the player's current inventory contents (last synced state).", {
422
+ username: z.string().describe("Player username"),
423
+ }, async ({ username }) => {
424
+ const data = await getPlayerSyncData(username);
425
+ if (!data) {
426
+ return { content: [{ type: "text", text: `No synced data found for "${username}".` }] };
427
+ }
428
+ if (!data.inventory) {
429
+ return { content: [{ type: "text", text: `No inventory data synced for "${username}".` }] };
430
+ }
431
+ const items = data.inventory.filter((i) => i.itemId !== -1);
432
+ if (items.length === 0) {
433
+ return { content: [{ type: "text", text: `${username}'s inventory is empty.` }] };
434
+ }
435
+ const lines = [`# ${username}'s Inventory — ${items.length} items`];
436
+ for (const item of items) {
437
+ const qty = item.quantity > 1 ? ` x${item.quantity.toLocaleString()}` : "";
438
+ lines.push(` [Slot ${item.slot}] ${item.name}${qty}`);
439
+ }
440
+ return { content: [{ type: "text", text: lines.join("\n") }] };
441
+ });
442
+ server.tool("get_my_diaries", "Get the player's achievement diary completion status. Optionally filter by region.", {
443
+ username: z.string().describe("Player username"),
444
+ region: z.string().optional().describe("Specific diary region (e.g. 'ARDOUGNE', 'VARROCK')"),
445
+ }, async ({ username, region }) => {
446
+ const data = await getPlayerSyncData(username);
447
+ if (!data) {
448
+ return { content: [{ type: "text", text: `No synced data found for "${username}".` }] };
449
+ }
450
+ if (!data.achievementDiaries) {
451
+ return { content: [{ type: "text", text: `No diary data synced for "${username}".` }] };
452
+ }
453
+ let diaries = Object.entries(data.achievementDiaries);
454
+ if (region) {
455
+ const key = region.toUpperCase();
456
+ diaries = diaries.filter(([r]) => r.toUpperCase() === key);
457
+ if (diaries.length === 0) {
458
+ return {
459
+ content: [
460
+ { type: "text", text: `Region "${region}" not found. Available: ${Object.keys(data.achievementDiaries).join(", ")}` },
461
+ ],
462
+ };
463
+ }
464
+ }
465
+ const lines = [`# ${username}'s Achievement Diaries`];
466
+ for (const [name, diary] of diaries) {
467
+ const check = (v) => (v ? "Done" : "---");
468
+ lines.push(` ${name}: Easy=${check(diary.easy)} | Med=${check(diary.medium)} | Hard=${check(diary.hard)} | Elite=${check(diary.elite)}`);
469
+ }
470
+ return { content: [{ type: "text", text: lines.join("\n") }] };
471
+ });
472
+ server.tool("get_my_combat_achievements", "Get the player's combat achievement completion status. Optionally search by task name.", {
473
+ username: z.string().describe("Player username"),
474
+ search: z.string().optional().describe("Search term to filter by task name"),
475
+ }, async ({ username, search }) => {
476
+ const data = await getPlayerSyncData(username);
477
+ if (!data) {
478
+ return { content: [{ type: "text", text: `No synced data found for "${username}".` }] };
479
+ }
480
+ if (!data.combatAchievements) {
481
+ return {
482
+ content: [{ type: "text", text: `No combat achievement data synced for "${username}".` }],
483
+ };
484
+ }
485
+ const ca = data.combatAchievements;
486
+ const lines = [`# ${username}'s Combat Achievements`];
487
+ lines.push(`Easy: ${ca.easyComplete ? "Complete" : "Incomplete"} | Medium: ${ca.mediumComplete ? "Complete" : "Incomplete"} | Hard: ${ca.hardComplete ? "Complete" : "Incomplete"} | Elite: ${ca.eliteComplete ? "Complete" : "Incomplete"}`);
488
+ lines.push(`Completed tasks: ${ca.completedTasks.length}`);
489
+ let tasks = ca.completedTasks;
490
+ if (search) {
491
+ const term = search.toLowerCase();
492
+ tasks = tasks.filter((t) => t.toLowerCase().includes(term));
493
+ lines.push(`\nMatching "${search}": ${tasks.length} tasks`);
494
+ }
495
+ if (tasks.length > 0 && tasks.length <= 100) {
496
+ lines.push("");
497
+ for (const task of tasks) {
498
+ lines.push(` [Done] ${task}`);
499
+ }
500
+ }
501
+ else if (tasks.length > 100) {
502
+ lines.push(`\nToo many tasks to display (${tasks.length}). Use the search parameter to filter.`);
503
+ }
504
+ return { content: [{ type: "text", text: lines.join("\n") }] };
505
+ });
506
+ // ── Start ───────────────────────────────────────────────────────────────
507
+ const transport = new StdioServerTransport();
508
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "osrs-companion",
3
+ "version": "1.0.0",
4
+ "description": "OSRS companion MCP - wiki search, GE prices, and local player data for AI assistants",
5
+ "type": "module",
6
+ "bin": {
7
+ "osrs-companion": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "start": "node dist/index.js",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": [
18
+ "osrs",
19
+ "runescape",
20
+ "runelite",
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "wiki",
24
+ "ai"
25
+ ],
26
+ "license": "BSD-2-Clause",
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.26.0",
29
+ "zod": "^3.25.76"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^24.5.2",
33
+ "typescript": "5.9.2"
34
+ }
35
+ }