| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>sodabar — open data on tap</title> |
| <style> |
| :root { |
| --bg: #fcfcfb; --panel: #ffffff; --ink: #1c2733; --ink2: #5a6a7a; --ink3: #8b98a5; |
| --line: #e3e8ee; --blue: #2a78d6; --orange: #eb6834; --blue-soft: #eaf2fc; |
| --chip: #f1f4f8; --code-bg: #f6f8fa; --ok: #1a7f4e; |
| } |
| @media (prefers-color-scheme: dark) { |
| :root { |
| --bg: #15191f; --panel: #1c222b; --ink: #e8edf2; --ink2: #a7b3c0; --ink3: #6f7d8c; |
| --line: #2c3540; --blue: #4a8fd9; --orange: #e8632f; --blue-soft: #1e2c3d; |
| --chip: #242c37; --code-bg: #12161c; --ok: #3dbf7f; |
| } |
| } |
| * { box-sizing: border-box; margin: 0; } |
| body { |
| background: var(--bg); color: var(--ink); |
| font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; |
| padding: 18px 22px; |
| } |
| header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 14px; } |
| h1 { font-size: 21px; letter-spacing: -0.02em; } |
| h1 .cup { color: var(--orange); } |
| .tag { color: var(--ink2); font-size: 13px; } |
| .live { margin-left: auto; font-size: 12px; color: var(--ok); white-space: nowrap; } |
| .live::before { content: "●"; margin-right: 5px; } |
| .grid { display: grid; grid-template-columns: 320px 1fr; gap: 14px; align-items: start; } |
| @media (max-width: 760px) { .grid { grid-template-columns: 1fr; } } |
| .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px; } |
| .tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 12px; } |
| .tool { |
| border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px; cursor: pointer; |
| background: none; color: var(--ink2); font: inherit; font-size: 12.5px; text-align: left; |
| } |
| .tool code { font-size: 11.5px; } |
| .tool.on { border-color: var(--blue); background: var(--blue-soft); color: var(--ink); } |
| label { display: block; font-size: 11.5px; color: var(--ink2); margin: 8px 0 3px; } |
| input, textarea { |
| width: 100%; border: 1px solid var(--line); border-radius: 7px; padding: 6px 8px; |
| background: var(--bg); color: var(--ink); font: 12.5px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; |
| } |
| input:focus, textarea:focus { outline: 2px solid var(--blue); outline-offset: -1px; border-color: transparent; } |
| .row { display: flex; gap: 8px; margin-top: 12px; } |
| .btn { |
| border: 0; border-radius: 8px; padding: 8px 14px; font: 600 13px/1 inherit; |
| cursor: pointer; font-family: inherit; |
| } |
| .btn.run { background: var(--blue); color: #fff; flex: 1; } |
| .btn.sample { background: var(--chip); color: var(--ink); } |
| .btn:active { transform: translateY(1px); } |
| .callbox { margin-bottom: 10px; } |
| .callbox .lbl, .out .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink3); margin-bottom: 5px; } |
| pre.call { |
| background: var(--code-bg); border: 1px solid var(--line); border-radius: 8px; |
| padding: 9px 11px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; |
| overflow-x: auto; color: var(--ink2); |
| } |
| pre.call .k { color: var(--blue); } |
| pre.call .v { color: var(--orange); } |
| .meta { font-size: 12px; color: var(--ink2); margin: 8px 0 6px; } |
| .meta b { color: var(--ink); } |
| table { border-collapse: collapse; width: 100%; font-size: 12.5px; } |
| th, td { text-align: left; padding: 5px 10px 5px 0; border-bottom: 1px solid var(--line); } |
| th { color: var(--ink2); font-weight: 600; font-size: 11.5px; } |
| td { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } |
| td.num { text-align: right; font-variant-numeric: tabular-nums; } |
| th.num { text-align: right; } |
| .chart { margin: 10px 0 4px; } |
| .bar-row { display: grid; grid-template-columns: 120px 1fr 70px; gap: 8px; align-items: center; margin-bottom: 4px; } |
| .bar-label { font-size: 12px; color: var(--ink2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; } |
| .bar-track { height: 14px; position: relative; } |
| .bar-fill { height: 100%; background: var(--blue); border-radius: 0 4px 4px 0; min-width: 2px; } |
| .bar-row:hover .bar-fill { background: var(--orange); } |
| .bar-val { font-size: 11.5px; color: var(--ink); font-variant-numeric: tabular-nums; } |
| .err { color: var(--orange); font-size: 13px; padding: 6px 0; } |
| .empty { color: var(--ink3); font-size: 13px; padding: 20px 0; text-align: center; } |
| .scroll { max-height: 300px; overflow: auto; } |
| footer { margin-top: 12px; font-size: 11.5px; color: var(--ink3); } |
| footer a { color: var(--blue); text-decoration: none; } |
| </style> |
| </head> |
| <body> |
| <header> |
| <h1>sodabar<span class="cup"> ▙</span></h1> |
| <span class="tag">open data on tap — the MCP tool console</span> |
| <span class="live" id="live">data.cityofnewyork.us</span> |
| </header> |
|
|
| <div class="grid"> |
| <div class="panel"> |
| <div class="tools" id="tools"></div> |
| <form id="form" onsubmit="return runTool(event)"><div id="fields"></div> |
| <div class="row"> |
| <button class="btn run" type="submit">Call tool</button> |
| <button class="btn sample" type="button" onclick="loadSample()">Try a sample</button> |
| </div> |
| </form> |
| </div> |
|
|
| <div class="panel"> |
| <div class="callbox"><div class="lbl">tools/call</div><pre class="call" id="call">{ }</pre></div> |
| <div class="out"> |
| <div class="lbl">result</div> |
| <div id="meta" class="meta"></div> |
| <div id="result"><div class="empty">Pick a tool and call it — or press “Try a sample”.</div></div> |
| </div> |
| </div> |
| </div> |
|
|
| <footer>Four MCP tools · SoQL queries capped at 1000 rows · same responses an LLM client sees</footer> |
|
|
| <script src="shim.js"></script> |
| <script> |
| const TOOLS = { |
| query_dataset: { |
| fields: [ |
| ["dataset_id", "erm2-nwe9"], ["select", ""], ["where", ""], |
| ["group", ""], ["order", ""], ["limit", "50"], |
| ], |
| endpoint: (a) => ["/api/query", a], |
| }, |
| search_datasets: { |
| fields: [["query", "rat sightings"], ["limit", "10"]], |
| endpoint: (a) => ["/api/search", { q: a.query, limit: a.limit }], |
| }, |
| get_schema: { |
| fields: [["dataset_id", "erm2-nwe9"]], |
| endpoint: (a) => [`/api/schema/${encodeURIComponent(a.dataset_id)}`, {}], |
| }, |
| profile_column: { |
| fields: [["dataset_id", "erm2-nwe9"], ["column", "borough"], ["top", "10"]], |
| endpoint: (a) => ["/api/profile", a], |
| }, |
| }; |
| let current = "query_dataset"; |
| let sampleIndex = 0; |
| |
| function renderTools() { |
| const box = document.getElementById("tools"); |
| box.innerHTML = ""; |
| for (const name of Object.keys(TOOLS)) { |
| const b = document.createElement("button"); |
| b.type = "button"; |
| b.className = "tool" + (name === current ? " on" : ""); |
| b.innerHTML = `<code>${name}</code>`; |
| b.onclick = () => { current = name; renderTools(); renderFields({}); }; |
| box.appendChild(b); |
| } |
| } |
| function renderFields(values) { |
| const box = document.getElementById("fields"); |
| box.innerHTML = ""; |
| for (const [name, placeholder] of TOOLS[current].fields) { |
| const l = document.createElement("label"); |
| l.textContent = name; |
| const i = document.createElement("input"); |
| i.name = name; |
| i.placeholder = placeholder; |
| if (values[name] !== undefined) i.value = values[name]; |
| box.append(l, i); |
| } |
| } |
| function formArgs() { |
| const args = {}; |
| for (const input of document.querySelectorAll("#fields input")) |
| if (input.value.trim()) args[input.name] = input.value.trim(); |
| return args; |
| } |
| function showCall(tool, args) { |
| const argsJson = JSON.stringify(args, null, 2).replace(/"([^"]+)":/g, '<span class="k">"$1"</span>:') |
| .replace(/: "([^"]*)"/g, ': <span class="v">"$1"</span>'); |
| document.getElementById("call").innerHTML = |
| `{ <span class="k">"name"</span>: <span class="v">"${tool}"</span>, <span class="k">"arguments"</span>: ${argsJson} }`; |
| } |
| async function runTool(ev) { |
| if (ev) ev.preventDefault(); |
| const args = formArgs(); |
| showCall(current, args); |
| const [path, params] = TOOLS[current].endpoint(args); |
| const qs = new URLSearchParams(params).toString(); |
| const t0 = performance.now(); |
| const resultBox = document.getElementById("result"); |
| const meta = document.getElementById("meta"); |
| meta.textContent = "…"; |
| resultBox.innerHTML = ""; |
| try { |
| const res = await fetch(path + (qs ? "?" + qs : "")); |
| const body = await res.json(); |
| const ms = Math.round(performance.now() - t0); |
| if (!res.ok) throw new Error(body.detail || res.statusText); |
| render(body, ms); |
| } catch (e) { |
| meta.textContent = ""; |
| resultBox.innerHTML = `<div class="err">✗ ${e.message}</div>`; |
| } |
| return false; |
| } |
| function render(body, ms) { |
| const meta = document.getElementById("meta"); |
| const box = document.getElementById("result"); |
| box.innerHTML = ""; |
| if (Array.isArray(body)) { |
| meta.innerHTML = `<b>${body.length}</b> row${body.length === 1 ? "" : "s"} · ${ms} ms`; |
| if (!body.length) { box.innerHTML = '<div class="empty">No rows.</div>'; return; } |
| const cols = Object.keys(body[0]); |
| if (cols.length === 2 && body.length <= 30 && body.every(r => isFinite(+r[cols[1]]))) { |
| box.appendChild(barChart(body, cols)); |
| } |
| box.appendChild(tableOf(body, cols)); |
| } else if (body && body.columns) { |
| meta.innerHTML = `<b>${body.name || body.id}</b> · ${body.columns.length} columns`; |
| box.appendChild(tableOf(body.columns.map(c => ({ column: c.field_name, type: c.type })), ["column", "type"])); |
| } else { |
| meta.textContent = ""; |
| box.innerHTML = `<pre class="call">${JSON.stringify(body, null, 2)}</pre>`; |
| } |
| } |
| function tableOf(rows, cols) { |
| const wrap = document.createElement("div"); |
| wrap.className = "scroll"; |
| const numeric = cols.map(c => rows.every(r => r[c] === undefined || isFinite(+r[c]))); |
| let html = "<table><tr>" + cols.map((c, i) => `<th${numeric[i] ? ' class="num"' : ""}>${c}</th>`).join("") + "</tr>"; |
| for (const r of rows.slice(0, 200)) |
| html += "<tr>" + cols.map((c, i) => { |
| let v = r[c] === undefined ? "" : String(r[c]); |
| if (numeric[i] && v) v = (+v).toLocaleString(); |
| return `<td${numeric[i] ? ' class="num"' : ""}>${escapeHtml(v).slice(0, 120)}</td>`; |
| }).join("") + "</tr>"; |
| wrap.innerHTML = html + "</table>"; |
| return wrap; |
| } |
| function barChart(rows, cols) { |
| const box = document.createElement("div"); |
| box.className = "chart"; |
| const max = Math.max(...rows.map(r => +r[cols[1]])); |
| for (const r of rows.slice(0, 12)) { |
| const v = +r[cols[1]]; |
| const row = document.createElement("div"); |
| row.className = "bar-row"; |
| row.title = `${r[cols[0]]}: ${v.toLocaleString()}`; |
| row.innerHTML = `<div class="bar-label">${escapeHtml(String(r[cols[0]] ?? "—"))}</div> |
| <div class="bar-track"><div class="bar-fill" style="width:${Math.max(1, v / max * 100)}%"></div></div> |
| <div class="bar-val">${v.toLocaleString()}</div>`; |
| box.appendChild(row); |
| } |
| return box; |
| } |
| function escapeHtml(s) { |
| return s.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); |
| } |
| async function loadSample() { |
| const res = await fetch(`/sample?index=${sampleIndex}`); |
| const s = await res.json(); |
| sampleIndex += 1; |
| current = s.tool; |
| renderTools(); |
| const values = {}; |
| for (const [k, v] of Object.entries(s.args)) values[k === "query" ? "query" : k] = v; |
| renderFields(values); |
| runTool(); |
| } |
| renderTools(); |
| renderFields({}); |
| const qs = new URLSearchParams(location.search); |
| if (qs.get("demo")) { |
| if (qs.get("err")) { |
| |
| current = "query_dataset"; |
| renderTools(); |
| renderFields({ dataset_id: "311-complaints", select: "borough, count(*)" }); |
| runTool(); |
| } else { |
| sampleIndex = +(qs.get("sample") || 0); |
| loadSample(); |
| } |
| } |
| </script> |
| </body> |
| </html> |
|
|