refactor(home/mcp): replace detached OSRS server

Remove the snapshot-based Node MCP implementation and point the OSRS server registry entry at the RuneLite plugin on IPv4 loopback.

Assisted-by: pi (openai-codex/gpt-5.6-sol)
This commit is contained in:
Gabriel Fontes
2026-07-24 14:52:44 -03:00
parent 53f0faf069
commit 1afce3eef6
7 changed files with 2 additions and 2667 deletions
+2 -8
View File
@@ -1,14 +1,8 @@
{
pkgs,
lib,
...
}: let
osrsMcp = pkgs.callPackage ./osrs {};
in {
{...}: {
programs.mcp = {
enable = true;
servers.osrs = {
command = lib.getExe osrsMcp;
url = "http://127.0.0.1:18471/mcp";
lifecycle = "lazy";
};
};
@@ -1,27 +0,0 @@
{
buildNpmPackage,
importNpmLock,
lib,
makeWrapper,
nodejs,
}:
buildNpmPackage {
pname = "osrs-mcp";
version = "0.1.0";
src = ./.;
npmDeps = importNpmLock {npmRoot = ./.;};
npmConfigHook = importNpmLock.npmConfigHook;
npmInstallFlags = ["--omit=dev"];
dontNpmBuild = true;
nativeBuildInputs = [makeWrapper];
installPhase = ''
mkdir -p $out/bin
cp -r . $out/
makeWrapper ${lib.getExe nodejs} $out/bin/osrs-mcp \
--add-flags $out/server.mjs
'';
meta.mainProgram = "osrs-mcp";
}
-634
View File
@@ -1,634 +0,0 @@
import { readFile, readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
export const USER_AGENT = "m7-osrs-mcp/0.1 (https://m7.rs)";
export const ACCOUNT_DIRECTORY =
process.env.OSRS_ACCOUNT_EXPORT_DIRECTORY ??
join(homedir(), ".runelite/account-data-exporter");
export const PROFILES_PATH =
process.env.RUNELITE_PROFILES_PATH ?? join(homedir(), ".runelite/profiles2");
const headers = { "User-Agent": USER_AGENT, Accept: "application/json" };
const STALE_AFTER_SECONDS = 60;
const REGION_AREAS = new Map([
// Western map square of The Great Conch, a Sailing island.
[12582, "The Great Conch"],
]);
const MAP_AREAS = [
["Grand Exchange", 3150, 3195, 3470, 3515],
["Lumbridge", 3190, 3265, 3180, 3265],
["Draynor Village", 3070, 3135, 3210, 3290],
["Port Sarim", 2990, 3075, 3160, 3255],
["Al Kharid", 3265, 3335, 3130, 3320],
["Varrock", 3135, 3295, 3370, 3530],
["Edgeville", 3060, 3135, 3470, 3525],
["Falador", 2940, 3070, 3280, 3400],
["Burthorpe", 2870, 2955, 3520, 3595],
["Taverley", 2870, 2955, 3390, 3520],
["Catherby", 2780, 2865, 3400, 3465],
["Seers' Village", 2680, 2760, 3450, 3515],
["Ardougne", 2490, 2675, 3250, 3345],
["Yanille", 2520, 2625, 3060, 3135],
["Tree Gnome Stronghold", 2400, 2505, 3380, 3520],
["Rellekka", 2610, 2725, 3620, 3715],
["Canifis", 3450, 3525, 3450, 3525],
["Prifddinas", 2150, 2305, 3250, 3405],
["Ape Atoll", 2680, 2825, 2680, 2825],
["Fossil Island", 3600, 3900, 3700, 4000],
["Karamja", 2750, 2985, 2800, 3200],
["Wilderness", 2940, 3400, 3520, 3970],
["Morytania", 3400, 3800, 3000, 3700],
["Great Kourend", 1200, 2100, 3400, 4100],
["Varlamore", 1200, 2100, 2750, 3400],
["Tirannwn", 2100, 2400, 3000, 3600],
["Kandarin", 2350, 2900, 3000, 3800],
["Asgarnia", 2800, 3100, 3150, 3700],
["Misthalin", 3050, 3400, 3100, 3550],
];
let itemMapping;
export function canonicalPlayerName(player) {
return player.trim().replaceAll("_", " ").replace(/\s+/g, " ").toLowerCase();
}
async function readAccountExports(directory) {
let files;
try {
files = (await readdir(directory)).filter((name) => name.endsWith(".json"));
} catch {
throw new Error(
"RuneLite account exports are unavailable. Start RuneLite with Account Data Exporter enabled.",
);
}
const exports = [];
for (const file of files) {
try {
const account = JSON.parse(await readFile(join(directory, file), "utf8"));
if (
typeof account.rsn === "string" &&
Number.isFinite(Date.parse(account.timestampIso))
) {
exports.push(account);
}
} catch {
// Ignore malformed or transiently incomplete exporter files.
}
}
return exports;
}
function newestAccount(left, right) {
return Date.parse(left.timestampIso) >= Date.parse(right.timestampIso)
? left
: right;
}
export async function discoverPlayers(directory = ACCOUNT_DIRECTORY) {
const accounts = await readAccountExports(directory);
const byPlayer = new Map();
for (const account of accounts) {
const key = canonicalPlayerName(account.rsn);
const previous = byPlayer.get(key);
byPlayer.set(key, previous ? newestAccount(previous, account) : account);
}
return [...byPlayer.values()]
.map((account) => ({
player: account.rsn,
accountType: account.accountTypeName,
combatLevel: account.combatLevel,
totalLevel: account.totalLevel,
freshness: freshness(account),
}))
.sort((left, right) => left.player.localeCompare(right.player));
}
export async function loadAccount(player, directory = ACCOUNT_DIRECTORY) {
if (!/^[A-Za-z0-9 _-]{1,12}$/.test(player)) {
throw new Error(`Invalid OSRS player name: ${player}`);
}
const wanted = canonicalPlayerName(player);
const matches = (await readAccountExports(directory)).filter(
(account) => canonicalPlayerName(account.rsn) === wanted,
);
if (matches.length === 0) {
throw new Error(
`No local RuneLite account export found for ${player}. Call players to discover available exports.`,
);
}
const account = matches.reduce(newestAccount);
if (!account.skills || !account.quests) {
throw new Error(`The local RuneLite export for ${player} is unsupported.`);
}
return account;
}
export function freshness(account, now = Date.now()) {
const timestampIso = timestampToIso(account.timestampIso);
const timestamp = Date.parse(timestampIso);
const ageSeconds = Number.isFinite(timestamp)
? Math.max(0, Math.round((now - timestamp) / 1000))
: null;
const stale = ageSeconds === null || ageSeconds > STALE_AFTER_SECONDS;
return {
timestampIso: timestampIso ?? null,
ageSeconds,
snapshotStatus: stale ? "STALE" : "CURRENT",
stale,
gameState: stale ? "STALE_SNAPSHOT" : account.gameState,
recordedGameState: account.gameState,
...(stale
? {
warning:
"This is an old RuneLite snapshot; recorded login state, location, inventory, and combat context may no longer be current.",
}
: {}),
};
}
export function resolveMapArea(location) {
if (location?.loaded !== true) return null;
const regionArea = REGION_AREAS.get(location.regionId);
if (regionArea) return regionArea;
const { worldX: x, worldY: y } = location;
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return (
MAP_AREAS.find(
([, minX, maxX, minY, maxY]) =>
x >= minX && x <= maxX && y >= minY && y <= maxY,
)?.[0] ?? null
);
}
export function unresolvedMapAreaReason(location) {
if (location?.loaded !== true) {
return "RuneLite did not provide a loaded location.";
}
if (!Number.isFinite(location.worldX) || !Number.isFinite(location.worldY)) {
return "RuneLite returned invalid world coordinates.";
}
return `No named map area mapping is available for region ${location.regionId ?? "unknown"}.`;
}
export function roundPercentages(value) {
if (Array.isArray(value))
return value.map((entry) => roundPercentages(entry));
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
key.endsWith("Percent") && typeof entry === "number"
? Math.round(entry * 100) / 100
: roundPercentages(entry),
]),
);
}
function compactValue(value) {
if (value === null || value === undefined || value === false || value === 0) {
return undefined;
}
if (typeof value === "string" && value.length === 0) return undefined;
if (Array.isArray(value)) {
const compacted = value
.map((entry) => compactValue(entry))
.filter((entry) => entry !== undefined);
return compacted.length > 0 ? compacted : undefined;
}
if (typeof value === "object") {
const compacted = Object.fromEntries(
Object.entries(value)
.map(([key, entry]) => [key, compactValue(entry)])
.filter(([, entry]) => entry !== undefined),
);
const keys = Object.keys(compacted);
if (keys.length === 0 || keys.every((key) => key === "slot")) {
return undefined;
}
return compacted;
}
return value;
}
export function compactSlayer(slayer) {
return compactValue(slayer);
}
export function timestampToIso(timestamp) {
if (timestamp === null || timestamp === undefined) return undefined;
const milliseconds =
typeof timestamp === "number" && Math.abs(timestamp) < 1_000_000_000_000
? timestamp * 1000
: timestamp;
const date = new Date(milliseconds);
return Number.isNaN(date.valueOf()) ? undefined : date.toISOString();
}
export function sectionMeta(section) {
if (!section || typeof section !== "object") {
return {
available: false,
currentAtSnapshot: false,
absenceMeaning: "unknown_section_unavailable",
};
}
const currentAtSnapshot =
section.loaded === true && section.fromCache !== true;
return {
available: true,
loaded: section.loaded,
fromCache: section.fromCache,
lastSeenTimestampIso: timestampToIso(section.lastSeenTimestamp),
currentAtSnapshot,
absenceMeaning: currentAtSnapshot
? "not_present_at_snapshot"
: "unknown_section_not_current_at_snapshot",
};
}
export function accountSummary(account, { includeRawSlayer = false } = {}) {
const diaries = account.achievementDiaries ?? {};
const achievements = account.combatAchievements ?? {};
const slayer = includeRawSlayer
? account.slayer
: compactSlayer(account.slayer);
return {
freshness: freshness(account),
identity: {
rsn: account.rsn,
accountType: account.accountTypeName,
combatLevel: account.combatLevel,
totalLevel: account.totalLevel,
totalXp: account.totalXp,
world: account.world,
},
quests: {
questPoints: account.quests.questPoints,
finished: account.quests.finished,
inProgress: account.quests.inProgress,
notStarted: account.quests.notStarted,
total: account.quests.total,
},
achievementDiaries: roundPercentages({
completedTiers: diaries.completedTierCount,
totalTiers: diaries.totalTierCount,
completionPercent: diaries.completionPercent,
}),
combatAchievements: {
completed: achievements.completed,
total: achievements.total,
tiers: achievements.tiers?.map(({ name, completed, total }) => ({
name,
completed,
total,
})),
},
...(slayer === undefined ? {} : { slayer }),
wealth: {
knownAccountValue: account.knownAccountValue,
grandExchangeEstimate: account.grandExchangeAccountValueEstimate,
knownAccountValueWithGeEstimate: account.knownAccountValueWithGeEstimate,
},
dataAvailability: Object.fromEntries(
[
"bank",
"inventory",
"equipment",
"seedVault",
"lootingBag",
"grandExchange",
"location",
].map((name) => [name, sectionMeta(account[name])]),
),
};
}
export function filterSkills(account, names = []) {
const wanted = names.map((name) => name.toLowerCase());
return Object.fromEntries(
Object.entries(account.skills).filter(
([name]) => wanted.length === 0 || wanted.includes(name.toLowerCase()),
),
);
}
export function filterQuests(account, states = [], query) {
const wantedStates = states.map((state) => state.toUpperCase());
const needle = query?.toLowerCase();
return account.quests.entries.filter(
(quest) =>
(wantedStates.length === 0 || wantedStates.includes(quest.state)) &&
(!needle || quest.name.toLowerCase().includes(needle)),
);
}
export function findItems(
account,
query,
containers,
{ includeEmptyContainers = false } = {},
) {
const needle = query.toLowerCase();
const searched = containers.map((container) => {
const section = account[container];
const matches = (section?.items ?? []).filter((item) =>
item.name.toLowerCase().includes(needle),
);
return {
container,
...sectionMeta(section),
itemCount: section?.itemCount,
value: section?.value,
matches,
};
});
return {
results: includeEmptyContainers
? searched
: searched.filter(({ matches }) => matches.length > 0),
searchedContainers: searched.map(
({ container, matches, itemCount, value, ...meta }) => ({
container,
...meta,
matchCount: matches.length,
}),
),
};
}
export function combatAchievements(account, { tier, completed, query, limit }) {
const needle = query?.toLowerCase();
let remaining = limit;
let matchedTaskCount = 0;
let returnedTaskCount = 0;
const tiers = (account.combatAchievements?.tiers ?? [])
.filter((entry) => !tier || entry.name.toLowerCase() === tier.toLowerCase())
.map((entry) => {
let tasks = entry.tasks ?? [];
if (completed !== undefined) {
tasks = tasks.filter((task) => task.completed === completed);
}
if (needle) {
tasks = tasks.filter((task) =>
task.name.toLowerCase().includes(needle),
);
}
const matchedInTier = tasks.length;
const returned = tasks.slice(0, remaining);
remaining -= returned.length;
matchedTaskCount += matchedInTier;
returnedTaskCount += returned.length;
return {
name: entry.name,
completed: entry.completed,
total: entry.total,
tasks: returned,
matchedTaskCount: matchedInTier,
};
});
return {
completed: account.combatAchievements?.completed,
total: account.combatAchievements?.total,
matchedTaskCount,
returnedTaskCount,
tiers,
};
}
export async function fetchJson(url) {
const response = await fetch(url, {
headers,
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText} from ${url}`);
}
const maxBytes = 10 * 1024 * 1024;
const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
throw new Error(`Response from ${url} exceeds ${maxBytes} bytes.`);
}
const body = await response.text();
if (Buffer.byteLength(body) > maxBytes) {
throw new Error(`Response from ${url} exceeds ${maxBytes} bytes.`);
}
return JSON.parse(body);
}
function meaningfulHiscore(entry) {
if (entry.rank === -1) return false;
if (typeof entry.xp === "number") return entry.xp > 0;
if (typeof entry.score === "number") return entry.score > 0;
return Object.entries(entry).some(
([key, value]) =>
!["id", "name", "rank"].includes(key) &&
typeof value === "number" &&
value > 0,
);
}
export function compactHiscores(hiscores) {
return Object.fromEntries(
Object.entries(hiscores).flatMap(([key, value]) => {
if (!Array.isArray(value)) return [[key, value]];
const entries = value.filter(meaningfulHiscore);
return entries.length > 0 ? [[key, entries]] : [];
}),
);
}
export async function fetchHiscores(player, { includeRaw = false } = {}) {
const url = new URL(
"https://secure.runescape.com/m=hiscore_oldschool/index_lite.json",
);
url.searchParams.set("player", player);
const hiscores = await fetchJson(url);
return includeRaw ? hiscores : compactHiscores(hiscores);
}
function searchTokens(value) {
return new Set(value.toLowerCase().match(/[a-z0-9]+/g) ?? []);
}
export function rankWikiResults(query, results, limit) {
const queryText = query.trim().toLowerCase();
const queryTokens = searchTokens(query);
return results
.map((result, index) => {
const title = result.title.toLowerCase();
const titleTokens = searchTokens(result.title);
const titleOverlap = [...queryTokens].filter((token) =>
titleTokens.has(token),
).length;
const exactBonus = title === queryText ? 1000 : 0;
const subpagePenalty = result.title.includes("/") ? 25 : 0;
const incidentalPenalty = /league|transcript|historical/.test(title)
? 50
: 0;
return {
result,
index,
score:
exactBonus + titleOverlap * 20 - subpagePenalty - incidentalPenalty,
};
})
.sort((left, right) => right.score - left.score || left.index - right.index)
.slice(0, limit)
.map(({ result }) => result);
}
export async function wikiSearch(query, limit) {
const url = new URL("https://oldschool.runescape.wiki/api.php");
url.search = new URLSearchParams({
action: "query",
list: "search",
srsearch: query,
srlimit: String(Math.min(50, Math.max(limit, limit * 4))),
srprop: "snippet|sectiontitle",
format: "json",
formatversion: "2",
});
const data = await fetchJson(url);
const results = data.query.search.map(({ title, snippet, sectiontitle }) => ({
title,
sectiontitle,
snippet: snippet.replace(/<[^>]+>/g, ""),
url: `https://oldschool.runescape.wiki/w/${encodeURIComponent(
title.replaceAll(" ", "_"),
)}`,
}));
return rankWikiResults(query, results, limit);
}
export function paginateText(text, maxCharacters, offset = 0) {
const extract = text.slice(offset, offset + maxCharacters);
const nextOffset = offset + extract.length;
const hasMore = nextOffset < text.length;
return {
extract,
offset,
returnedCharacters: extract.length,
truncated: hasMore,
nextOffset: hasMore ? nextOffset : null,
...(hasMore
? {
continuationHint: `Call wiki_page again with the same title and offset=${nextOffset} to continue.`,
}
: {}),
totalCharacters: text.length,
};
}
export async function wikiPage(title, maxCharacters, offset = 0) {
const url = new URL("https://oldschool.runescape.wiki/api.php");
url.search = new URLSearchParams({
action: "query",
prop: "extracts|info",
titles: title,
explaintext: "1",
redirects: "1",
inprop: "url",
format: "json",
formatversion: "2",
});
const data = await fetchJson(url);
const page = data.query.pages[0];
if (page.missing) throw new Error(`OSRS Wiki page not found: ${title}`);
const extract = page.extract ?? "";
return {
title: page.title,
url: page.fullurl,
...paginateText(extract, maxCharacters, offset),
};
}
async function getItemMapping() {
itemMapping ??= fetchJson(
"https://prices.runescape.wiki/api/v1/osrs/mapping",
);
return itemMapping;
}
export async function itemPrices(query, limit) {
const needle = query.toLowerCase();
const mapping = (await getItemMapping())
.filter((item) => item.name.toLowerCase().includes(needle))
.slice(0, limit);
const latest = await fetchJson(
"https://prices.runescape.wiki/api/v1/osrs/latest",
);
return mapping.map((item) => ({
id: item.id,
name: item.name,
examine: item.examine,
members: item.members,
buyLimit: item.limit,
value: item.value,
alch: { high: item.highalch, low: item.lowalch },
market: normalizeMarket(latest.data[String(item.id)]),
}));
}
export function normalizeMarket(market) {
if (!market) return null;
const { highTime, lowTime, ...prices } = market;
return {
...prices,
highTimeIso: timestampToIso(highTime),
lowTimeIso: timestampToIso(lowTime),
};
}
function decodeJavaProperty(value) {
return value.replace(/\\(u[0-9a-fA-F]{4}|n|r|t|f|.)/g, (_, escape) => {
if (escape.startsWith("u")) {
return String.fromCharCode(Number.parseInt(escape.slice(1), 16));
}
return { n: "\n", r: "\r", t: "\t", f: "\f" }[escape] ?? escape;
});
}
export async function readRuneLiteNotes(
directory = PROFILES_PATH,
maxCharacters = 10_000,
{ profile, includeEmpty = false } = {},
) {
const wantedProfile = profile?.toLowerCase().replace(/\.properties$/, "");
let files;
try {
files = (await readdir(directory)).filter(
(name) =>
name.endsWith(".properties") &&
(!wantedProfile ||
name.toLowerCase().replace(/\.properties$/, "") === wantedProfile),
);
} catch {
throw new Error("RuneLite Notes profiles are unavailable.");
}
const notes = [];
for (const file of files) {
let content;
try {
content = await readFile(join(directory, file), "utf8");
} catch {
if (wantedProfile)
throw new Error("RuneLite Notes profile is unavailable.");
continue;
}
const lines = content.match(/(?:^|\n)notes\.notesData=(.*(?:\\\n.*)*)/);
if (lines) {
const fullText = decodeJavaProperty(lines[1]);
if (!includeEmpty && fullText.trim().length === 0) continue;
notes.push({
profile: file,
text: fullText.slice(0, maxCharacters),
truncated: fullText.length > maxCharacters,
totalCharacters: fullText.length,
});
}
}
return notes;
}
File diff suppressed because it is too large Load Diff
@@ -1,18 +0,0 @@
{
"name": "osrs-mcp",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"test": "node --test",
"format": "prettier --write \"*.mjs\" \"test/*.mjs\"",
"format:check": "prettier --check \"*.mjs\" \"test/*.mjs\""
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0",
"zod": "4.3.6"
},
"devDependencies": {
"prettier": "3.8.2"
}
}
@@ -1,393 +0,0 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import * as z from "zod/v4";
import {
accountSummary,
combatAchievements,
compactSlayer,
discoverPlayers,
fetchHiscores,
filterQuests,
filterSkills,
findItems,
freshness,
itemPrices,
loadAccount,
readRuneLiteNotes,
resolveMapArea,
roundPercentages,
sectionMeta,
unresolvedMapAreaReason,
wikiPage,
wikiSearch,
} from "./lib.mjs";
const server = new McpServer({ name: "osrs", version: "0.1.0" });
const text = (value) => ({
content: [
{
type: "text",
text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
},
],
});
const tool = (name, options, handler) =>
server.registerTool(name, options, async (args) => {
try {
return text(await handler(args));
} catch (error) {
return text({
error: {
message: error instanceof Error ? error.message : String(error),
},
});
}
});
const itemContainers = [
"bank",
"inventory",
"equipment",
"seedVault",
"lootingBag",
"seedBox",
"tackleBox",
"forestryKit",
"huntsmansKit",
];
const playerSchema = z
.string()
.min(1)
.max(12)
.regex(/^[A-Za-z0-9 _-]+$/)
.describe(
"OSRS display name. Local snapshot tools require an export discoverable with players; hiscores accepts any player.",
);
tool(
"players",
{
description:
"Discover players with local RuneLite account exports. Call this before player-specific tools when the display name is unknown.",
inputSchema: {},
},
async () => ({ players: await discoverPlayers() }),
);
tool(
"account_summary",
{
description:
"Read a player's RuneLite snapshot and summarize account identity, freshness, quests, diaries, Combat Achievements, meaningful Slayer state, wealth, and which private sections are loaded. Old snapshots are explicitly marked stale. Call this before giving account-specific progression advice.",
inputSchema: {
player: playerSchema,
includeRawSlayer: z
.boolean()
.default(false)
.describe("Include empty and zero-only raw Slayer data."),
},
},
async ({ player, includeRawSlayer }) =>
accountSummary(await loadAccount(player), { includeRawSlayer }),
);
tool(
"skills",
{
description:
"Read current OSRS skill levels, boosted levels, and XP from a player's RuneLite snapshot. Returns every skill unless names are supplied.",
inputSchema: {
player: playerSchema,
names: z
.array(z.string())
.optional()
.describe(
'Case-insensitive skill names, for example ["prayer", "Ranged"].',
),
},
},
async ({ player, names = [] }) => {
const account = await loadAccount(player);
return {
freshness: freshness(account),
skills: filterSkills(account, names),
};
},
);
tool(
"quests",
{
description:
"Read a player's live quest completion state. Filter unfinished quests for Quest Point Cape planning or look up a named quest.",
inputSchema: {
player: playerSchema,
states: z
.array(z.enum(["FINISHED", "IN_PROGRESS", "NOT_STARTED", "UNKNOWN"]))
.optional()
.describe("Quest states to include; omit for all."),
query: z
.string()
.optional()
.describe("Case-insensitive quest-name substring."),
},
},
async ({ player, states = [], query }) => {
const account = await loadAccount(player);
return {
freshness: freshness(account),
summary: {
questPoints: account.quests.questPoints,
finished: account.quests.finished,
inProgress: account.quests.inProgress,
notStarted: account.quests.notStarted,
total: account.quests.total,
},
quests: filterQuests(account, states, query),
};
},
);
tool(
"find_items",
{
description:
"Search a player's bank, inventory, equipment, and auxiliary containers by item name. Returns matching containers plus a compact summary of every searched container so absence is not mistaken for non-ownership.",
inputSchema: {
player: playerSchema,
query: z
.string()
.min(1)
.describe("Case-insensitive item-name substring."),
containers: z
.array(z.enum(itemContainers))
.optional()
.describe(
"Containers to search; defaults to all supported containers.",
),
includeEmptyContainers: z
.boolean()
.default(false)
.describe("Return full result objects for containers without matches."),
},
},
async ({
player,
query,
containers = itemContainers,
includeEmptyContainers,
}) => {
const account = await loadAccount(player);
return {
freshness: freshness(account),
...findItems(account, query, containers, { includeEmptyContainers }),
};
},
);
tool(
"live_state",
{
description:
"Read current coordinates and their named map area, status, combat state, animation, inventory, and equipment from RuneLite for contextual play assistance. Old snapshots are explicitly marked stale. Read-only; never controls gameplay.",
inputSchema: {
player: playerSchema,
includeItems: z
.boolean()
.default(true)
.describe("Include inventory and equipment item lists."),
includeRawAnimation: z
.boolean()
.default(false)
.describe("Include raw RuneLite animation and orientation IDs."),
},
},
async ({ player, includeItems, includeRawAnimation }) => {
const account = await loadAccount(player);
const summarizeContainer = (name) => ({
...sectionMeta(account[name]),
itemCount: account[name]?.itemCount,
value: account[name]?.value,
...(includeItems ? { items: account[name]?.items ?? [] } : {}),
});
const mapArea = resolveMapArea(account.location);
return {
freshness: freshness(account),
world: account.world,
location: {
...account.location,
mapArea,
...(mapArea === null
? {
mapAreaUnresolvedReason: unresolvedMapAreaReason(
account.location,
),
}
: {}),
},
status: account.status,
combat: account.combat,
animation: includeRawAnimation
? account.animation
: {
state:
typeof account.animation?.current === "number" &&
account.animation.current >= 0
? "ANIMATING"
: "IDLE",
},
inventory: summarizeContainer("inventory"),
equipment: summarizeContainer("equipment"),
};
},
);
tool(
"progression",
{
description:
"Read detailed Achievement Diary, Combat Achievement, Slayer, or Grand Exchange progression from a player's RuneLite snapshot. Combat Achievement task output is bounded and filterable.",
inputSchema: {
player: playerSchema,
section: z.enum([
"achievement_diaries",
"combat_achievements",
"slayer",
"grand_exchange",
]),
tier: z.string().optional().describe("Combat Achievement tier name."),
completed: z
.boolean()
.optional()
.describe("Filter Combat Achievement tasks."),
query: z
.string()
.optional()
.describe("Combat Achievement task-name substring."),
limit: z
.number()
.int()
.min(1)
.max(200)
.default(50)
.describe("Maximum Combat Achievement tasks across all tiers."),
includeRawSlayer: z
.boolean()
.default(false)
.describe("Include empty and zero-only raw Slayer data."),
},
},
async ({
player,
section,
tier,
completed,
query,
limit,
includeRawSlayer,
}) => {
const account = await loadAccount(player);
const data = {
achievement_diaries: roundPercentages(account.achievementDiaries),
combat_achievements: combatAchievements(account, {
tier,
completed,
query,
limit,
}),
slayer: includeRawSlayer ? account.slayer : compactSlayer(account.slayer),
grand_exchange: account.grandExchange,
}[section];
return { freshness: freshness(account), section, data };
},
);
tool(
"hiscores",
{
description:
"Fetch public official OSRS Hiscores JSON for a player's meaningful skills, activities, and ranked boss kill counts. Empty, zero-only, and unranked entries are omitted by default. This source works even when RuneLite is closed.",
inputSchema: {
player: playerSchema,
includeRaw: z
.boolean()
.default(false)
.describe("Return every raw Hiscores entry, including empty ones."),
},
},
async ({ player, includeRaw }) => fetchHiscores(player, { includeRaw }),
);
tool(
"wiki_search",
{
description:
"Search the current Old School RuneScape Wiki for quests, mechanics, training methods, items, bosses, or guides before giving factual game advice. Results favor exact titles and main-game pages over incidental subpage matches.",
inputSchema: {
query: z.string().min(1),
limit: z.number().int().min(1).max(20).default(8),
},
},
async ({ query, limit }) => wikiSearch(query, limit),
);
tool(
"wiki_page",
{
description:
"Read a current OSRS Wiki page as plain text. Use after wiki_search for requirements, mechanics, methods, and recommendations. Continue truncated pages by passing the returned nextOffset.",
inputSchema: {
title: z
.string()
.min(1)
.describe("Exact or redirectable wiki page title."),
maxCharacters: z.number().int().min(1000).max(50000).default(20000),
offset: z
.number()
.int()
.min(0)
.default(0)
.describe("Character offset; use nextOffset to continue a page."),
},
},
async ({ title, maxCharacters, offset }) =>
wikiPage(title, maxCharacters, offset),
);
tool(
"item_prices",
{
description:
"Look up current RuneLite/OSRS Wiki real-time Grand Exchange high and low prices plus alch values and buy limits by item-name substring.",
inputSchema: {
query: z.string().min(1),
limit: z.number().int().min(1).max(25).default(10),
},
},
async ({ query, limit }) => itemPrices(query, limit),
);
tool(
"runelite_notes",
{
description:
"Read the user-authored RuneLite Notes plugin text from local profiles as an optional player-to-agent message channel.",
inputSchema: {
profile: z
.string()
.optional()
.describe("Exact RuneLite profile name, with or without .properties."),
includeEmpty: z
.boolean()
.default(false)
.describe("Include profiles whose Notes text is empty."),
maxCharacters: z.number().int().min(100).max(20_000).default(10_000),
},
},
async ({ profile, includeEmpty, maxCharacters }) =>
readRuneLiteNotes(undefined, maxCharacters, { profile, includeEmpty }),
);
await server.connect(new StdioServerTransport());
@@ -1,389 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
accountSummary,
combatAchievements,
compactHiscores,
compactSlayer,
discoverPlayers,
filterQuests,
filterSkills,
findItems,
freshness,
loadAccount,
normalizeMarket,
paginateText,
rankWikiResults,
readRuneLiteNotes,
resolveMapArea,
roundPercentages,
sectionMeta,
timestampToIso,
unresolvedMapAreaReason,
} from "../lib.mjs";
const account = {
timestampIso: new Date().toISOString(),
gameState: "LOGGED_IN",
rsn: "Test User",
accountTypeName: "Normal",
combatLevel: 99,
totalLevel: 1500,
totalXp: 12_345_678,
world: 420,
skills: {
Attack: { level: 75, boostedLevel: 75, xp: 1_210_421 },
Prayer: { level: 60, boostedLevel: 65, xp: 273_742 },
},
quests: {
questPoints: 200,
finished: 100,
inProgress: 1,
notStarted: 20,
total: 121,
entries: [
{ id: "KINGS_RANSOM", name: "King's Ransom", state: "NOT_STARTED" },
{ id: "COOKS_ASSISTANT", name: "Cook's Assistant", state: "FINISHED" },
],
},
achievementDiaries: {
completedTierCount: 2,
totalTierCount: 48,
completionPercent: 35.416666666666664,
},
combatAchievements: {
completed: 1,
total: 2,
tiers: [
{
name: "Easy",
completed: 1,
total: 2,
tasks: [
{ id: 1, name: "Easy does it", completed: true },
{ id: 2, name: "Another task", completed: false },
],
},
{
name: "Medium",
completed: 0,
total: 1,
tasks: [{ id: 3, name: "Medium task", completed: false }],
},
],
},
slayer: {
points: 0,
tasksCompletedStreak: 0,
currentTask: { hasTask: false },
},
bank: {
loaded: true,
fromCache: false,
lastSeenTimestamp: 1_700_000_000_000,
itemCount: 2,
value: 1000,
items: [
{ id: 1, name: "Dragon defender", quantity: 1, value: 0 },
{ id: 2, name: "Dragon dagger", quantity: 2, value: 1000 },
],
},
inventory: { loaded: false, items: [] },
equipment: { loaded: true, items: [] },
};
test("deduplicates and normalizes local account lookups", async () => {
const directory = await mkdtemp(join(tmpdir(), "osrs-mcp-"));
await writeFile(join(directory, "Test User.json"), JSON.stringify(account));
await writeFile(
join(directory, "latest.json"),
JSON.stringify({
...account,
timestampIso: "2020-01-01T00:00:00.000Z",
totalLevel: 100,
}),
);
await writeFile(join(directory, "broken.json"), "{");
await writeFile(
join(directory, "invalid-time.json"),
JSON.stringify({
...account,
timestampIso: "eventually",
totalLevel: 9999,
}),
);
await writeFile(join(directory, "ignore.txt"), "not an export");
assert.equal((await loadAccount("test_user", directory)).rsn, "Test User");
const players = await discoverPlayers(directory);
assert.equal(players.length, 1);
assert.equal(players[0].player, "Test User");
assert.equal(players[0].totalLevel, 1500);
await assert.rejects(
loadAccount("Missing", directory),
(error) =>
error.message.includes("No local RuneLite account export found") &&
!error.message.includes(directory),
);
});
test("summarizes without item lists or empty Slayer data", () => {
const summary = accountSummary(account);
assert.equal(summary.identity.rsn, "Test User");
assert.equal(summary.quests.questPoints, 200);
assert.equal(summary.dataAvailability.bank.loaded, true);
assert.equal("items" in summary.dataAvailability.bank, false);
assert.equal(summary.achievementDiaries.completionPercent, 35.42);
assert.equal("slayer" in summary, false);
assert.deepEqual(
accountSummary(account, { includeRawSlayer: true }).slayer,
account.slayer,
);
});
test("marks old snapshots stale instead of reporting a current login", () => {
const result = freshness(
{ timestampIso: "2026-01-01T00:00:00.000Z", gameState: "LOGGED_IN" },
Date.parse("2026-01-01T00:02:00.000Z"),
);
assert.equal(result.snapshotStatus, "STALE");
assert.equal(result.gameState, "STALE_SNAPSHOT");
assert.equal(result.recordedGameState, "LOGGED_IN");
assert.equal(
freshness(
{
timestampIso: "2026-01-01T01:00:00.000+01:00",
gameState: "LOGGED_IN",
},
Date.parse("2026-01-01T00:00:00.000Z"),
).timestampIso,
"2026-01-01T00:00:00.000Z",
);
});
test("resolves coordinates to the most specific named map area", () => {
assert.equal(
resolveMapArea({
loaded: true,
worldX: 3184,
worldY: 2455,
regionId: 12582,
}),
"The Great Conch",
);
assert.equal(
resolveMapArea({ loaded: true, worldX: 3222, worldY: 3218 }),
"Lumbridge",
);
assert.equal(
resolveMapArea({ loaded: true, worldX: 3165, worldY: 3490 }),
"Grand Exchange",
);
const unresolved = {
loaded: true,
worldX: 10_000,
worldY: 10_000,
regionId: 99_999,
};
assert.equal(resolveMapArea(unresolved), null);
assert.equal(
unresolvedMapAreaReason(unresolved),
"No named map area mapping is available for region 99999.",
);
assert.equal(resolveMapArea({ loaded: false }), null);
assert.equal(
unresolvedMapAreaReason({ loaded: false }),
"RuneLite did not provide a loaded location.",
);
});
test("compacts zero-only Slayer and Hiscores data", () => {
assert.equal(compactSlayer(account.slayer), undefined);
assert.deepEqual(
compactSlayer({
...account.slayer,
superiorCreaturesDefeated: 3,
blocks: [
{ slot: 1, monster: "", active: false },
{ slot: 2, monster: "Abyssal demon", active: true },
],
}),
{
superiorCreaturesDefeated: 3,
blocks: [{ slot: 2, monster: "Abyssal demon", active: true }],
},
);
const raw = {
skills: [
{ id: 0, name: "Overall", rank: 1, level: 1500, xp: 12_345_678 },
{ id: 1, name: "Attack", rank: -1, level: 10, xp: 1_154 },
],
activities: [
{ id: 0, name: "Clue Scrolls", rank: -1, score: -1 },
{ id: 1, name: "Vorkath", rank: 100, score: 25 },
],
bosses: [{ id: 0, name: "Zulrah", rank: -1, score: -1 }],
};
assert.deepEqual(compactHiscores(raw), {
skills: [raw.skills[0]],
activities: [raw.activities[1]],
});
});
test("ranks canonical Wiki titles above incidental subpage matches", () => {
const incidental = { title: "Demonic Pacts League/Areas/Morytania" };
const canonical = { title: "Hallowed Sepulchre" };
assert.deepEqual(
rankWikiResults(
"Hallowed Sepulchre agility requirements",
[incidental, canonical, { title: "Agility" }],
3,
)[0],
canonical,
);
});
test("rounds percentage fields recursively", () => {
assert.deepEqual(
roundPercentages({
completionPercent: 35.416666666666664,
tiers: [{ completionPercent: 1.2345 }],
exactValue: 1.2345,
}),
{
completionPercent: 35.42,
tiers: [{ completionPercent: 1.23 }],
exactValue: 1.2345,
},
);
});
test("paginates Wiki extracts with a continuation offset", () => {
assert.deepEqual(paginateText("abcdef", 2), {
extract: "ab",
offset: 0,
returnedCharacters: 2,
truncated: true,
nextOffset: 2,
continuationHint:
"Call wiki_page again with the same title and offset=2 to continue.",
totalCharacters: 6,
});
assert.deepEqual(paginateText("abcdef", 2, 4), {
extract: "ef",
offset: 4,
returnedCharacters: 2,
truncated: false,
nextOffset: null,
totalCharacters: 6,
});
});
test("normalizes snapshot-adjacent timestamps and section metadata", () => {
assert.equal(timestampToIso(1_700_000_000), "2023-11-14T22:13:20.000Z");
assert.equal(timestampToIso(1_700_000_000_000), "2023-11-14T22:13:20.000Z");
assert.deepEqual(sectionMeta(account.bank), {
available: true,
loaded: true,
fromCache: false,
lastSeenTimestampIso: "2023-11-14T22:13:20.000Z",
currentAtSnapshot: true,
absenceMeaning: "not_present_at_snapshot",
});
assert.deepEqual(
normalizeMarket({ high: 100, highTime: 1_700_000_000, lowTime: null }),
{
high: 100,
highTimeIso: "2023-11-14T22:13:20.000Z",
lowTimeIso: undefined,
},
);
});
test("filters skills and quest state case-insensitively", () => {
assert.deepEqual(Object.keys(filterSkills(account, ["prayer"])), ["Prayer"]);
assert.deepEqual(filterQuests(account, ["NOT_STARTED"], "ransom"), [
account.quests.entries[0],
]);
});
test("item searches compact empty containers but summarize the search", () => {
const result = findItems(account, "dragon", ["bank", "inventory"]);
assert.equal(result.results.length, 1);
assert.equal(result.results[0].matches.length, 2);
assert.equal(result.results[0].loaded, true);
assert.deepEqual(
result.searchedContainers.map(({ container, matchCount }) => ({
container,
matchCount,
})),
[
{ container: "bank", matchCount: 2 },
{ container: "inventory", matchCount: 0 },
],
);
assert.equal(
findItems(account, "dragon", ["bank", "inventory"], {
includeEmptyContainers: true,
}).results.length,
2,
);
});
test("combat achievement output is filtered and globally bounded", () => {
const filtered = combatAchievements(account, {
tier: "easy",
completed: false,
query: "task",
limit: 1,
});
assert.equal(filtered.tiers[0].matchedTaskCount, 1);
assert.equal(filtered.tiers[0].tasks[0].name, "Another task");
const global = combatAchievements(account, { limit: 2 });
assert.equal(global.matchedTaskCount, 3);
assert.equal(global.returnedTaskCount, 2);
assert.equal(global.tiers[0].tasks.length, 2);
assert.equal(global.tiers[1].tasks.length, 0);
});
test("filters and selects RuneLite Notes profiles", async () => {
const directory = await mkdtemp(join(tmpdir(), "osrs-notes-"));
await writeFile(
join(directory, "Main.properties"),
"other.value=x\nnotes.notesData=HELLO\\nWORLD\\u0021\n",
);
await writeFile(join(directory, "Empty.properties"), "notes.notesData=\n");
const expected = {
profile: "Main.properties",
text: "HELLO\nWORLD!",
truncated: false,
totalCharacters: 12,
};
assert.deepEqual(await readRuneLiteNotes(directory), [expected]);
assert.deepEqual(
await readRuneLiteNotes(directory, 10_000, { profile: "main" }),
[expected],
);
assert.equal(
(
await readRuneLiteNotes(directory, 10_000, {
profile: "Empty.properties",
includeEmpty: true,
})
).length,
1,
);
const missing = join(directory, "missing");
await assert.rejects(
readRuneLiteNotes(missing),
(error) =>
error.message === "RuneLite Notes profiles are unavailable." &&
!error.message.includes(missing),
);
});