We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/sepa79/rpg-ledger-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<title>RPG Ledger MCP UI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #020617;
color: #e5e7eb;
display: flex;
height: 100vh;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px 24px;
box-sizing: border-box;
overflow: hidden;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.topbar-title {
font-size: 18px;
font-weight: 600;
}
.topbar select {
width: auto;
min-width: 200px;
margin-bottom: 0;
}
h1 {
font-size: 18px;
margin: 0 0 12px;
}
h2, h3 {
margin-top: 0;
}
select {
width: 100%;
padding: 6px 8px;
margin-bottom: 12px;
background: #020617;
color: #e5e7eb;
border: 1px solid #1f2937;
border-radius: 4px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 9999px;
background: #1d4ed8;
font-size: 11px;
margin-right: 6px;
}
.card {
background: #020617;
border-radius: 12px;
border: 1px solid #1f2937;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 10px 25px rgba(15,23,42,0.6);
}
.card-header-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.muted {
color: #9ca3af;
font-size: 13px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(70px, 1fr));
gap: 8px;
margin: 8px 0 12px;
}
.stat-box {
background: #020617;
border-radius: 8px;
border: 1px solid #1f2937;
padding: 6px 8px;
text-align: center;
font-size: 12px;
}
.stat-label {
font-size: 11px;
color: #9ca3af;
}
ul {
margin: 6px 0;
padding-left: 20px;
font-size: 14px;
}
.gold {
font-weight: 600;
color: #fbbf24;
}
.notes {
white-space: pre-wrap;
font-size: 13px;
color: #d1d5db;
}
.layout-top {
display: flex;
gap: 16px;
flex: 1;
overflow: hidden;
}
.column {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.logs {
flex: 0.9;
overflow-y: auto;
margin-top: 8px;
}
.log-entry {
font-size: 12px;
border-bottom: 1px solid #1f2937;
padding: 4px 0;
}
.log-op {
font-weight: 600;
}
.log-ts {
color: #6b7280;
font-size: 11px;
margin-right: 4px;
}
.pill {
display: inline-block;
padding: 1px 6px;
border-radius: 9999px;
border: 1px solid #374151;
font-size: 11px;
background: #111827;
margin-right: 4px;
}
.party-card-wrapper {
margin-top: 12px;
}
.party-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 8px;
}
.character-card {
background: #020617;
border-radius: 10px;
border: 1px solid #1f2937;
padding: 10px 12px;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease;
}
.character-card:hover {
border-color: #2563eb;
box-shadow: 0 10px 25px rgba(37,99,235,0.35);
transform: translateY(-1px);
}
.character-card.selected {
border-color: #22c55e;
box-shadow: 0 0 0 1px rgba(34,197,94,0.5);
}
.character-name-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 6px;
margin-bottom: 4px;
}
.character-name-row h3 {
margin: 0;
font-size: 15px;
}
.character-subline {
font-size: 12px;
color: #9ca3af;
margin-bottom: 6px;
}
.bar-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 6px;
}
.bar-label {
font-size: 11px;
color: #9ca3af;
display: flex;
justify-content: space-between;
}
.bar {
position: relative;
height: 6px;
border-radius: 9999px;
background: #020617;
border: 1px solid #1f2937;
overflow: hidden;
}
.bar-fill {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 0%;
border-radius: inherit;
}
.bar-fill.hp {
background: linear-gradient(90deg, #ef4444, #22c55e);
}
.bar-fill.xp {
background: linear-gradient(90deg, #0ea5e9, #a855f7);
}
.inventory-preview {
font-size: 12px;
color: #9ca3af;
min-height: 16px;
}
.quick-actions {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.quick-btn {
border: 1px solid #1f2937;
background: #020617;
color: #e5e7eb;
border-radius: 9999px;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
}
.quick-btn:hover {
border-color: #2563eb;
background: #0b1120;
}
</style>
</head>
<body>
<div class="main">
<div class="topbar">
<div class="topbar-title">RPG Ledger MCP</div>
<div>
<span class="muted" style="font-size:12px; margin-right:6px;">Kampania</span>
<select id="campaignSelect"></select>
</div>
</div>
<div class="layout-top">
<div class="column">
<div id="campaignSummary"></div>
<div id="worldMap"></div>
<div id="partyGrid" class="party-card-wrapper"></div>
<div id="characterDetail"></div>
</div>
<div class="column">
<div class="card">
<div class="card-header-row" style="margin-bottom:8px;">
<div class="badge">Panel</div>
<div style="display:flex; gap:4px;">
<button class="quick-btn" id="tabLogs" data-tab="logs">Log MCP</button>
<button class="quick-btn" id="tabTodos" data-tab="todos">TODOs</button>
</div>
</div>
<p class="muted" id="panelSubtitle">Ostatnie mutacje wykonywane przez agenta / MCP.</p>
<div id="logs" class="logs"></div>
<div id="todos" class="logs" style="display:none;"></div>
</div>
</div>
</div>
</div>
<script>
const campaignSelect = document.getElementById("campaignSelect");
const campaignSummaryEl = document.getElementById("campaignSummary");
const partyGridEl = document.getElementById("partyGrid");
const worldMapEl = document.getElementById("worldMap");
const characterDetailEl = document.getElementById("characterDetail");
const logsEl = document.getElementById("logs");
const todosEl = document.getElementById("todos");
const tabLogsBtn = document.getElementById("tabLogs");
const tabTodosBtn = document.getElementById("tabTodos");
const panelSubtitleEl = document.getElementById("panelSubtitle");
let currentCampaign = null;
let selectedCharacterId = null;
let currentPanelTab = "logs";
async function loadCampaigns() {
const res = await fetch("/api/campaigns");
const campaigns = await res.json();
campaignSelect.innerHTML = "";
campaigns.forEach((c) => {
const opt = document.createElement("option");
opt.value = c.id;
opt.textContent = c.name;
campaignSelect.appendChild(opt);
});
if (campaigns.length > 0) {
await loadCampaign(campaigns[0].id);
}
}
async function loadCampaign(id) {
const res = await fetch(`/api/campaigns/${id}`);
currentCampaign = await res.json();
renderCampaignSummary(currentCampaign);
renderWorldMap(currentCampaign);
renderParty(currentCampaign);
const chars = currentCampaign.characters || [];
if (chars.length) {
selectedCharacterId = chars[0].id;
renderCharacterDetail(chars[0]);
} else {
characterDetailEl.innerHTML = "<p class='muted'>Brak postaci w tej kampanii.</p>";
}
}
function renderCampaignSummary(c) {
campaignSummaryEl.innerHTML = `
<div class="card">
<div class="card-header-row">
<div>
<div class="badge">Kampania</div>
<h2>${c.name}</h2>
</div>
<div class="muted" style="text-align:right; font-size:12px;">
Dzie: <strong>${c.day ?? "-"}</strong><br />
Lokacja: <strong>${c.location ?? "-"}</strong>
</div>
</div>
${c.notes ? `<p class="notes" style="margin-top:8px;">${c.notes}</p>` : ""}
</div>
`;
}
function renderWorldMap(campaign) {
const locations = campaign.locations || [];
const currentName = campaign.location || "";
if (!locations.length && !currentName) {
worldMapEl.innerHTML = "";
return;
}
const rows = locations.length
? locations
: [{ id: currentName || "place", name: currentName || "Nieznane miejsce", type: "place", tags: [], description: "" }];
const rowsHtml = rows
.map((loc) => {
const name = loc.name || loc.id;
const type = loc.type || "place";
const tags = loc.tags || [];
const isCurrent = name === currentName;
const tagsHtml = tags.map((t) => `<span class=\"pill\">${t}</span>`).join(" ");
return `
<div class=\"log-entry\">
<div>
<span class=\"muted\">${type}</span>
<strong style=\"margin-left:4px;\">${name}</strong>
${tagsHtml}
</div>
<div style=\"margin-top:4px; display:flex; align-items:center; gap:8px; font-size:12px;\">
${isCurrent ? `<span class=\\"pill\\">Tu jesteście</span>` : `<button class=\\"quick-btn world-travel\\" data-location-name=\\"${name}\\">Podróż tutaj</button>`}
${loc.description ? `<span class=\\"muted\\">${loc.description}</span>` : ""}
</div>
</div>
`;
})
.join("");
worldMapEl.innerHTML = `
<div class="card">
<div class="card-header-row">
<div>
<div class="badge">Mapa świata</div>
<p class="muted" style="margin: 4px 0 0; font-size:12px;">Lista znanych lokacji i aktualne położenie drużyny.</p>
</div>
</div>
<div class="logs" style="margin-top:8px; max-height:220px;">
${rowsHtml}
</div>
</div>
`;
}
function renderParty(campaign) {
const characters = campaign.characters || [];
if (!characters.length) {
partyGridEl.innerHTML = "";
return;
}
const cardsHtml = characters
.map((ch) => {
const inv = ch.inventory || [];
const invPreview = inv
.slice(0, 3)
.map((item) => `${item.name}${item.qty && item.qty !== 1 ? ` (x${item.qty})` : ""}`)
.join(", ");
const hp = ch.hp ?? 0;
const maxHp = ch.maxHp ?? ch.hp ?? 1;
const hpPercent = Math.max(0, Math.min(100, (hp / maxHp) * 100));
const xp = ch.xp ?? 0;
const xpPercent = 100;
const selectedClass = ch.id === selectedCharacterId ? " selected" : "";
return `
<div class="character-card${selectedClass}" data-char-id="${ch.id}">
<div class="character-name-row">
<h3>${ch.name}</h3>
<span class="gold">${ch.gold ?? 0} gp</span>
</div>
<div class="character-subline">
${ch.class || "?"} · lvl ${ch.level ?? "-"}
</div>
<div class="bar-row">
<div class="bar-label">
<span>HP</span>
<span>${hp}/${maxHp}</span>
</div>
<div class="bar">
<div class="bar-fill hp" style="width: ${hpPercent}%;"></div>
</div>
<div class="bar-label">
<span>XP</span>
<span>${xp}</span>
</div>
<div class="bar">
<div class="bar-fill xp" style="width: ${xpPercent}%;"></div>
</div>
</div>
<div class="inventory-preview">
${invPreview || "Brak widocznych przedmiotów."}
</div>
<div class="quick-actions">
<button class="quick-btn" data-action="hp_minus" data-char-id="${ch.id}">-5 HP</button>
<button class="quick-btn" data-action="hp_plus" data-char-id="${ch.id}">+5 HP</button>
<button class="quick-btn" data-action="gold_minus" data-char-id="${ch.id}">-5 gp</button>
<button class="quick-btn" data-action="gold_plus" data-char-id="${ch.id}">+5 gp</button>
<button class="quick-btn" data-action="loot" data-char-id="${ch.id}">Loot</button>
<button class="quick-btn" data-action="note" data-char-id="${ch.id}">Note</button>
</div>
</div>
`;
})
.join("");
partyGridEl.innerHTML = `
<div class="card">
<div class="card-header-row">
<div>
<div class="badge">Drużyna</div>
<p class="muted" style="margin: 4px 0 0;">Kliknij kartę, aby otworzyć pełny widok postaci.</p>
</div>
</div>
<div class="party-grid">
${cardsHtml}
</div>
</div>
`;
}
function renderCharacterDetail(ch) {
const stats = ch.stats || {};
const inventory = ch.inventory || [];
characterDetailEl.innerHTML = `
<div class="card">
<div class="badge">Postać</div>
<h2>${ch.name}</h2>
<p class="muted">
Klasa: <strong>${ch.class || "-"}</strong>
·
Poziom: <strong>${ch.level ?? "-"}</strong>
·
XP: <strong>${ch.xp ?? "-"}</strong>
</p>
<p class="muted">
HP: <strong>${ch.hp ?? "-"}</strong>
·
Złoto: <span class="gold">${ch.gold ?? 0}</span>
</p>
<div class="stats-grid">
${Object.entries(stats)
.map(
([key, val]) => `
<div class="stat-box">
<div class="stat-label">${key.toUpperCase()}</div>
<div>${val}</div>
</div>
`
)
.join("")}
</div>
<h3>Ekwipunek</h3>
${
inventory.length
? `<ul>${inventory
.map(
(item) =>
`<li>${item.name} ${
item.qty != null && item.qty !== 1
? `<span class="muted">(x${item.qty})</span>`
: ""
}</li>`
)
.join("")}</ul>`
: `<p class="muted">Pusto w plecaku.</p>`
}
${
ch.notes
? `<h3>Notatki</h3><p class="notes">${ch.notes}</p>`
: ""
}
</div>
`;
}
async function loadLogs() {
try {
const res = await fetch("/api/logs?limit=50");
const logs = await res.json();
renderLogs(logs);
renderTodos(logs);
} catch (e) {
console.error("Failed to load logs", e);
}
}
function renderLogs(logs) {
const gameLogs = logs.filter(
(entry) => entry.type === "mutate" || entry.type === "history"
);
if (!gameLogs.length) {
logsEl.innerHTML = "<p class='muted'>Brak zarejestrowanych mutacji.</p>";
return;
}
logsEl.innerHTML = gameLogs
.map((entry) => {
const ts = entry.ts || "";
const op = entry.op || "";
const cid = entry.campaign_id || "";
const charId = entry.char_id || "";
const amount = entry.amount;
const value = entry.value;
const text = entry.text;
let valueStr = "";
if (value !== null && value !== undefined) {
if (typeof value === "object") {
try {
valueStr = JSON.stringify(value);
} catch (e) {
valueStr = String(value);
}
} else {
valueStr = String(value);
}
}
return `
<div class="log-entry">
<span class="log-ts">${ts}</span>
<span class="pill">${cid || "-"}</span>
${
charId
? `<span class="pill">char: ${charId}</span>`
: ""
}
<span class="log-op">${op}</span>
${
amount != null
? `<span class="muted">amount=${amount}</span>`
: ""
}
${
valueStr
? `<span class="muted">value=${valueStr}</span>`
: ""
}
${
text
? `<div class="muted" style="margin-top:2px;">${text}</div>`
: ""
}
</div>
`;
})
.join("");
}
function renderTodos(logs) {
const todos = [];
logs.forEach((entry) => {
if (entry.type === "todo") {
todos.push({
ts: entry.ts,
summary: entry.summary || entry.text || "TODO",
details: entry.details || "",
tags: entry.tags || [],
done: !!entry.done,
comment: entry.comment || "",
});
} else if (entry.type === "history" && typeof entry.text === "string" && entry.text.startsWith("TODO")) {
let details = "";
if (entry.value) {
if (typeof entry.value === "string") {
details = entry.value;
} else if (typeof entry.value === "object" && entry.value.details) {
details = entry.value.details;
}
}
todos.push({
ts: entry.ts,
summary: entry.text,
details,
tags: (entry.value && entry.value.tags) || [],
done: false,
comment: "",
});
}
});
if (!todos.length) {
todosEl.innerHTML = "<p class='muted'>Brak TODO w logach.</p>";
return;
}
todosEl.innerHTML = todos
.map((todo) => {
const done = !!todo.done;
const comment = todo.comment || "";
const tagsHtml = (todo.tags || [])
.map((t) => `<span class=\"pill\">${t}</span>`)
.join(" ");
const safeComment = comment.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `
<div class=\"log-entry\" data-todo-ts=\"${todo.ts}\">
<div>
<span class=\"log-ts\">${todo.ts}</span>
${tagsHtml}
</div>
<div class=\"muted\" style=\"margin-top:2px;\">${todo.summary}</div>
${todo.details ? `<div class=\\"muted\\" style=\\"margin-top:2px; white-space:pre-wrap;\\">${todo.details}</div>` : ""}
<div style=\"margin-top:4px; font-size:12px; display:flex; flex-wrap:wrap; gap:6px; align-items:center;\">
<label style=\"display:flex; align-items:center; gap:4px;\">
<input type=\"checkbox\" class=\"todo-done\" ${done ? "checked" : ""} />
<span class=\"muted\">Zrobione</span>
</label>
<input type=\"text\" class=\"todo-comment\" placeholder=\"Komentarz implementacji...\" value=\"${safeComment}\" style=\"flex:1; min-width:180px; padding:2px 6px; border-radius:4px; border:1px solid #1f2937; background:#020617; color:#e5e7eb;\" />
<button class=\"quick-btn todo-save\">Zapisz</button>
</div>
</div>
`;
})
.join("");
}
campaignSelect.addEventListener("change", (e) => {
const id = e.target.value;
if (id) loadCampaign(id);
});
tabLogsBtn.addEventListener("click", () => {
currentPanelTab = "logs";
logsEl.style.display = "block";
todosEl.style.display = "none";
panelSubtitleEl.textContent = "Ostatnie mutacje wykonywane przez agenta / MCP.";
});
tabTodosBtn.addEventListener("click", () => {
currentPanelTab = "todos";
logsEl.style.display = "none";
todosEl.style.display = "block";
panelSubtitleEl.textContent = "TODO z logów (dev_todo / TODO: ...)";
});
todosEl.addEventListener("click", (e) => {
const saveBtn = e.target.closest(".todo-save");
if (!saveBtn) return;
const container = saveBtn.closest(".log-entry");
if (!container) return;
const ts = container.getAttribute("data-todo-ts");
const doneEl = container.querySelector(".todo-done");
const commentEl = container.querySelector(".todo-comment");
const status = doneEl && doneEl.checked ? "done" : "open";
const comment = commentEl ? commentEl.value : "";
fetch("/api/todo-status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ todo_ts: ts, status, comment }),
})
.then((res) => {
if (!res.ok) throw new Error("failed");
return res.json();
})
.then(() => loadLogs())
.catch((err) => console.error("failed to update todo status", err));
});
function selectCharacterById(id) {
if (!currentCampaign) return;
const ch = (currentCampaign.characters || []).find((c) => c.id === id);
if (ch) {
selectedCharacterId = id;
renderCharacterDetail(ch);
renderParty(currentCampaign);
}
}
async function callMutate(payload) {
const res = await fetch("/api/mutate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
console.error("mutate failed", await res.text());
return;
}
const data = await res.json();
currentCampaign = data;
if (selectedCharacterId) {
selectCharacterById(selectedCharacterId);
} else {
renderCampaignSummary(currentCampaign);
renderParty(currentCampaign);
}
renderWorldMap(currentCampaign);
loadLogs();
}
function handleQuickAction(action, charId) {
if (!currentCampaign) return;
const campaignId = currentCampaign.id;
if (!campaignId) return;
if (action === "hp_minus" || action === "hp_plus") {
const delta = action === "hp_minus" ? -5 : 5;
callMutate({
campaign_id: campaignId,
op: "hp_add",
char_id: charId,
amount: delta,
});
} else if (action === "gold_minus" || action === "gold_plus") {
const delta = action === "gold_minus" ? -5 : 5;
callMutate({
campaign_id: campaignId,
op: "gold_add",
char_id: charId,
amount: delta,
});
} else if (action === "loot") {
const name = prompt("Nazwa przedmiotu do dodania:");
if (!name) return;
const qtyInput = prompt("Ilość (domyślnie 1):", "1");
const qty = parseInt(qtyInput || "1", 10) || 1;
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item";
callMutate({
campaign_id: campaignId,
op: "inventory_add",
char_id: charId,
value: { id, name, qty },
text: name,
});
} else if (action === "note") {
const text = prompt("Notatka dla postaci:");
if (!text) return;
callMutate({
campaign_id: campaignId,
op: "char_note_add",
char_id: charId,
text,
});
}
}
partyGridEl.addEventListener("click", (e) => {
const actionBtn = e.target.closest("[data-action]");
if (actionBtn) {
const action = actionBtn.getAttribute("data-action");
const charId = actionBtn.getAttribute("data-char-id");
handleQuickAction(action, charId);
e.stopPropagation();
return;
}
const card = e.target.closest(".character-card");
if (card) {
const charId = card.getAttribute("data-char-id");
selectCharacterById(charId);
}
});
worldMapEl.addEventListener("click", (e) => {
const btn = e.target.closest(".world-travel");
if (!btn) return;
const name = btn.getAttribute("data-location-name");
if (!name || !currentCampaign) return;
callMutate({
campaign_id: currentCampaign.id,
op: "location_set",
value: name,
});
});
loadCampaigns().catch((err) => {
console.error(err);
campaignSummaryEl.innerHTML =
"<p class='muted'>Nie udało się załadować kampanii.</p>";
});
loadLogs();
setInterval(loadLogs, 5000);
</script>
</body>
</html>