@loguro/mcp 1.0.0 → 1.2.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 (2) hide show
  1. package/dist/index.js +850 -7
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -19560,6 +19560,263 @@ class StdioServerTransport {
19560
19560
  import { existsSync, readFileSync } from "node:fs";
19561
19561
  import { homedir } from "node:os";
19562
19562
  import { join } from "node:path";
19563
+
19564
+ // src/docs-catalog.ts
19565
+ var DOCS_CATALOG = [
19566
+ { path: "/docs/getting-started", title: "Getting Started", section: "General" },
19567
+ { path: "/docs/query-syntax", title: "Query Syntax", section: "General" },
19568
+ { path: "/docs/alerting", title: "Alerting", section: "General" },
19569
+ { path: "/docs/integrations", title: "Integrations", section: "General" },
19570
+ { path: "/docs/cli/install", title: "Install", section: "CLI" },
19571
+ { path: "/docs/cli/getting-started", title: "Getting Started", section: "CLI" },
19572
+ { path: "/docs/cli/auth", title: "Auth", section: "CLI" },
19573
+ { path: "/docs/cli/project-link", title: "Project Link", section: "CLI" },
19574
+ { path: "/docs/cli/commands", title: "Commands", section: "CLI" },
19575
+ { path: "/docs/cli/querying", title: "Querying", section: "CLI" },
19576
+ { path: "/docs/cli/query-syntax", title: "Query Syntax", section: "CLI" },
19577
+ { path: "/docs/cli/investigate", title: "Investigate", section: "CLI" },
19578
+ { path: "/docs/cli/visualizations", title: "Visualizations", section: "CLI" },
19579
+ { path: "/docs/cli/replay", title: "Replay", section: "CLI" },
19580
+ { path: "/docs/cli/alerts", title: "Alerts", section: "CLI" },
19581
+ { path: "/docs/cli/integrations", title: "Integrations", section: "CLI" },
19582
+ { path: "/docs/cli/share", title: "Share", section: "CLI" },
19583
+ { path: "/docs/cli/account", title: "Account", section: "CLI" },
19584
+ { path: "/docs/commands/count", title: "--count", section: "Commands" },
19585
+ { path: "/docs/commands/top", title: "--top", section: "Commands" },
19586
+ { path: "/docs/commands/unique", title: "--unique", section: "Commands" },
19587
+ { path: "/docs/commands/rate", title: "--rate", section: "Commands" },
19588
+ { path: "/docs/commands/diff", title: "--diff", section: "Commands" },
19589
+ { path: "/docs/commands/downloads", title: "--downloads", section: "Commands" },
19590
+ { path: "/docs/commands/replay", title: "--replay", section: "Commands" },
19591
+ { path: "/docs/commands/live", title: "--live", section: "Commands" },
19592
+ { path: "/docs/commands/group", title: "--group", section: "Commands" },
19593
+ { path: "/docs/commands/alerts", title: "--alerts", section: "Commands" },
19594
+ { path: "/docs/commands/views", title: "--views", section: "Commands" },
19595
+ { path: "/docs/commands/keys", title: "--keys", section: "Commands" },
19596
+ { path: "/docs/commands/channels", title: "--channels", section: "Commands" },
19597
+ { path: "/docs/commands/embed-status", title: "--embed::status", section: "Commands" },
19598
+ { path: "/docs/commands/billing", title: "--billing", section: "Commands" },
19599
+ { path: "/docs/commands/usage", title: "--usage", section: "Commands" },
19600
+ { path: "/docs/commands/integrations-config", title: "--integrations::config", section: "Commands" },
19601
+ { path: "/docs/commands/send", title: "--send", section: "Commands" },
19602
+ { path: "/docs/commands/tasks", title: "--task", section: "Commands" },
19603
+ { path: "/docs/commands/slow", title: "--slow", section: "Commands" },
19604
+ { path: "/docs/commands/sample", title: "--sample", section: "Commands" },
19605
+ { path: "/docs/commands/memory", title: "--memory", section: "Commands" },
19606
+ { path: "/docs/commands/chart", title: "--chart", section: "Commands" },
19607
+ { path: "/docs/commands/timeline", title: "--timeline", section: "Commands" },
19608
+ { path: "/docs/commands/notifications", title: "--notifications", section: "Commands" },
19609
+ { path: "/docs/commands/severity", title: "--severity", section: "Commands" },
19610
+ { path: "/docs/commands/separate-message", title: "--separate::message", section: "Commands" },
19611
+ { path: "/docs/commands/no-group", title: "--no-group", section: "Commands" },
19612
+ { path: "/docs/commands/hashtag", title: "#view", section: "Commands" },
19613
+ { path: "/docs/commands/investigate", title: "--investigate", section: "Commands" },
19614
+ { path: "/docs/commands/explain", title: "--explain", section: "Commands" },
19615
+ { path: "/docs/commands/notes", title: "--notes", section: "Commands" },
19616
+ { path: "/docs/commands/share", title: "--share:md", section: "Commands" },
19617
+ { path: "/docs/commands/slack", title: "--slack", section: "Commands" },
19618
+ { path: "/docs/commands/trace", title: "--trace:full", section: "Commands" },
19619
+ { path: "/docs/features/saved-views", title: "Saved Views", section: "Features" },
19620
+ { path: "/docs/features/log-export", title: "Log Export", section: "Features" },
19621
+ { path: "/docs/features/embed-widgets", title: "Embed Widgets", section: "Features" },
19622
+ { path: "/docs/features/replay", title: "Replay", section: "Features" },
19623
+ { path: "/docs/features/issue-tracker", title: "Issue Tracker", section: "Features" },
19624
+ { path: "/docs/features/messaging", title: "Messaging", section: "Features" },
19625
+ { path: "/docs/features/heartbeat", title: "Heartbeat", section: "Features" },
19626
+ { path: "/docs/features/memory", title: "Memory", section: "Features" },
19627
+ { path: "/docs/mcp/setup", title: "Setup", section: "MCP" },
19628
+ { path: "/docs/mcp/auth", title: "Auth", section: "MCP" },
19629
+ { path: "/docs/mcp/config", title: "Config", section: "MCP" },
19630
+ { path: "/docs/mcp/tools", title: "Tools", section: "MCP" },
19631
+ { path: "/docs/mcp/clients", title: "Clients", section: "MCP" },
19632
+ { path: "/docs/mcp/vs-cli", title: "MCP vs CLI", section: "MCP" },
19633
+ { path: "/docs/tui/getting-started", title: "Getting Started", section: "TUI" },
19634
+ { path: "/docs/tui/views", title: "Views", section: "TUI" },
19635
+ { path: "/docs/tui/keymap", title: "Keymap", section: "TUI" },
19636
+ { path: "/docs/tui/search", title: "Search", section: "TUI" },
19637
+ { path: "/docs/tui/alerts", title: "Alerts", section: "TUI" },
19638
+ { path: "/docs/tui/saved-views", title: "Saved Views", section: "TUI" },
19639
+ { path: "/docs/tui/docs-browser", title: "Docs Browser", section: "TUI" },
19640
+ { path: "/docs/tui/health-badge", title: "Health Badge", section: "TUI" },
19641
+ { path: "/docs/tui/troubleshooting", title: "Troubleshooting", section: "TUI" },
19642
+ { path: "/docs/web-analytics/getting-started", title: "Getting Started", section: "Web Analytics" },
19643
+ { path: "/docs/web-analytics/install", title: "Install", section: "Web Analytics" },
19644
+ { path: "/docs/web-analytics/events", title: "Events", section: "Web Analytics" },
19645
+ { path: "/docs/web-analytics/pageviews", title: "Pageviews", section: "Web Analytics" },
19646
+ { path: "/docs/web-analytics/identity", title: "Identity", section: "Web Analytics" },
19647
+ { path: "/docs/web-analytics/privacy", title: "Privacy", section: "Web Analytics" },
19648
+ { path: "/docs/web-analytics/api-reference", title: "API Reference", section: "Web Analytics" },
19649
+ { path: "/docs/web-analytics/troubleshooting", title: "Troubleshooting", section: "Web Analytics" }
19650
+ ];
19651
+
19652
+ // src/docs.ts
19653
+ var DOCS_BASE_URL = (process.env.LOGURO_DOCS_BASE_URL ?? "https://logu.ro").replace(/\/$/, "");
19654
+ var CACHE_TTL_MS = 15 * 60 * 1000;
19655
+ var FETCH_TIMEOUT_MS = 1e4;
19656
+ var USER_AGENT = `loguro-mcp-docs/1.0.0 (node/${process.version})`;
19657
+ var cache = new Map;
19658
+ var allFetchedAt = 0;
19659
+ function extractHeadings(md) {
19660
+ const out = [];
19661
+ for (const line of md.split(`
19662
+ `)) {
19663
+ const m = line.match(/^(#{1,6})\s+(.+?)\s*$/);
19664
+ if (m)
19665
+ out.push(m[2].trim());
19666
+ }
19667
+ return out;
19668
+ }
19669
+ function isFresh(entry) {
19670
+ return Date.now() - entry.fetchedAt < CACHE_TTL_MS;
19671
+ }
19672
+ async function fetchDoc(path, force = false) {
19673
+ const cached2 = cache.get(path);
19674
+ if (!force && cached2 && isFresh(cached2))
19675
+ return cached2;
19676
+ const url = `${DOCS_BASE_URL}${path}.md`;
19677
+ const res = await fetch(url, {
19678
+ headers: { "User-Agent": USER_AGENT, Accept: "text/markdown,text/plain,*/*" },
19679
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
19680
+ });
19681
+ if (!res.ok) {
19682
+ throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
19683
+ }
19684
+ const body = await res.text();
19685
+ const entry = {
19686
+ path,
19687
+ body,
19688
+ headings: extractHeadings(body),
19689
+ fetchedAt: Date.now()
19690
+ };
19691
+ cache.set(path, entry);
19692
+ return entry;
19693
+ }
19694
+ async function fetchAllDocs() {
19695
+ if (Date.now() - allFetchedAt < CACHE_TTL_MS)
19696
+ return;
19697
+ await Promise.all(DOCS_CATALOG.map((entry) => fetchDoc(entry.path).catch((err) => {
19698
+ process.stderr.write(`docs: failed to fetch ${entry.path}: ${err.message}
19699
+ `);
19700
+ })));
19701
+ allFetchedAt = Date.now();
19702
+ }
19703
+ function listDocs(section) {
19704
+ const filtered = section ? DOCS_CATALOG.filter((e) => e.section.toLowerCase() === section.toLowerCase()) : DOCS_CATALOG;
19705
+ return filtered.map((e) => {
19706
+ const cached2 = cache.get(e.path);
19707
+ return cached2 ? { ...e, headings: cached2.headings } : { ...e };
19708
+ });
19709
+ }
19710
+ function listSections() {
19711
+ const counts = new Map;
19712
+ for (const e of DOCS_CATALOG)
19713
+ counts.set(e.section, (counts.get(e.section) ?? 0) + 1);
19714
+ return [...counts.entries()].map(([section, count]) => ({ section, count }));
19715
+ }
19716
+ function tokens(s) {
19717
+ return s.toLowerCase().split(/\s+/).filter(Boolean);
19718
+ }
19719
+ function countOccurrences(haystack, needle) {
19720
+ if (!needle)
19721
+ return 0;
19722
+ let count = 0;
19723
+ let i = 0;
19724
+ const lc = haystack.toLowerCase();
19725
+ while ((i = lc.indexOf(needle, i)) !== -1) {
19726
+ count++;
19727
+ i += needle.length;
19728
+ }
19729
+ return count;
19730
+ }
19731
+ function buildSnippet(body, query, max = 280) {
19732
+ const lc = body.toLowerCase();
19733
+ const idx = lc.indexOf(query.toLowerCase());
19734
+ if (idx === -1) {
19735
+ return body.slice(0, max).trim() + (body.length > max ? "…" : "");
19736
+ }
19737
+ const start = Math.max(0, idx - 80);
19738
+ const end = Math.min(body.length, idx + query.length + 200);
19739
+ const prefix = start > 0 ? "…" : "";
19740
+ const suffix = end < body.length ? "…" : "";
19741
+ return prefix + body.slice(start, end).replace(/\s+/g, " ").trim() + suffix;
19742
+ }
19743
+ async function searchDocs(query, limit = 5) {
19744
+ await fetchAllDocs();
19745
+ const q = query.trim().toLowerCase();
19746
+ const qTokens = tokens(q);
19747
+ if (!q)
19748
+ return [];
19749
+ const hits = [];
19750
+ for (const entry of DOCS_CATALOG) {
19751
+ const cached2 = cache.get(entry.path);
19752
+ const titleLc = entry.title.toLowerCase();
19753
+ const sectionLc = entry.section.toLowerCase();
19754
+ const matchedIn = new Set;
19755
+ let score = 0;
19756
+ const matchedHeadings = [];
19757
+ if (titleLc.includes(q)) {
19758
+ score += 50;
19759
+ matchedIn.add("title");
19760
+ } else {
19761
+ for (const t of qTokens)
19762
+ if (titleLc.includes(t)) {
19763
+ score += 15;
19764
+ matchedIn.add("title");
19765
+ }
19766
+ }
19767
+ if (sectionLc.includes(q)) {
19768
+ score += 10;
19769
+ matchedIn.add("section");
19770
+ }
19771
+ if (cached2) {
19772
+ for (const h of cached2.headings) {
19773
+ const hLc = h.toLowerCase();
19774
+ if (hLc.includes(q)) {
19775
+ score += 20;
19776
+ matchedIn.add("heading");
19777
+ matchedHeadings.push(h);
19778
+ } else {
19779
+ for (const t of qTokens) {
19780
+ if (hLc.includes(t)) {
19781
+ score += 5;
19782
+ matchedIn.add("heading");
19783
+ if (!matchedHeadings.includes(h))
19784
+ matchedHeadings.push(h);
19785
+ }
19786
+ }
19787
+ }
19788
+ }
19789
+ const bodyOccurrences = countOccurrences(cached2.body, q);
19790
+ if (bodyOccurrences > 0) {
19791
+ score += Math.min(bodyOccurrences, 10);
19792
+ matchedIn.add("body");
19793
+ } else {
19794
+ for (const t of qTokens) {
19795
+ const occ = countOccurrences(cached2.body, t);
19796
+ if (occ > 0) {
19797
+ score += Math.min(occ, 5) * 0.5;
19798
+ matchedIn.add("body");
19799
+ }
19800
+ }
19801
+ }
19802
+ }
19803
+ if (score > 0) {
19804
+ hits.push({
19805
+ path: entry.path,
19806
+ title: entry.title,
19807
+ section: entry.section,
19808
+ score,
19809
+ matchedIn: [...matchedIn],
19810
+ snippet: cached2 ? buildSnippet(cached2.body, q) : "",
19811
+ matchedHeadings
19812
+ });
19813
+ }
19814
+ }
19815
+ hits.sort((a, b) => b.score - a.score);
19816
+ return hits.slice(0, limit);
19817
+ }
19818
+
19819
+ // src/index.ts
19563
19820
  function loadCliAuth() {
19564
19821
  const xdg = process.env.XDG_CONFIG_HOME;
19565
19822
  const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".config");
@@ -19577,13 +19834,21 @@ var cliAuth = loadCliAuth();
19577
19834
  var TOKEN = process.env.LOGURO_TOKEN ?? cliAuth.token;
19578
19835
  var BASE_URL = (process.env.LOGURO_BASE_URL ?? cliAuth.baseUrl ?? "https://logu.ro").replace(/\/$/, "");
19579
19836
  var DEFAULT_PROJECT = process.env.LOGURO_PROJECT;
19837
+ var ENV_TRUNCATE_LIMIT = (() => {
19838
+ const raw = process.env.LOGURO_TRUNCATE_LIMIT;
19839
+ if (!raw)
19840
+ return 500;
19841
+ const n = parseInt(raw, 10);
19842
+ return Number.isFinite(n) && n > 0 ? n : 500;
19843
+ })();
19844
+ var TRUNCATABLE_TOP_FIELDS = ["message", "stack", "stackTrace", "error", "errorMessage", "raw", "body"];
19580
19845
  if (!TOKEN) {
19581
19846
  process.stderr.write("Error: no PAT found. Run `loguro login` first, or set LOGURO_TOKEN env var.\n");
19582
19847
  process.exit(1);
19583
19848
  }
19584
- var USER_AGENT = `loguro-mcp/1.0.0 (node/${process.version})`;
19849
+ var USER_AGENT2 = `loguro-mcp/1.0.0 (node/${process.version})`;
19585
19850
  var REQUEST_TIMEOUT_MS = 15000;
19586
- async function logsRequest(project, params) {
19851
+ function buildQS(params) {
19587
19852
  const qs = new URLSearchParams;
19588
19853
  for (const [key, value] of Object.entries(params)) {
19589
19854
  if (value === undefined || value === null)
@@ -19595,11 +19860,34 @@ async function logsRequest(project, params) {
19595
19860
  qs.set(key, String(value));
19596
19861
  }
19597
19862
  }
19598
- const url = `${BASE_URL}/api/logs/${encodeURIComponent(project)}?${qs}`;
19863
+ return qs;
19864
+ }
19865
+ async function logsRequest(project, params) {
19866
+ const url = `${BASE_URL}/api/logs/${encodeURIComponent(project)}?${buildQS(params)}`;
19867
+ const res = await fetch(url, {
19868
+ headers: {
19869
+ Authorization: `Bearer ${TOKEN}`,
19870
+ "User-Agent": USER_AGENT2,
19871
+ Accept: "application/json"
19872
+ },
19873
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
19874
+ });
19875
+ if (!res.ok) {
19876
+ const text = await res.text();
19877
+ throw new Error(`Loguro API error ${res.status}: ${text}`);
19878
+ }
19879
+ return res.json();
19880
+ }
19881
+ async function logsSubRequest(project, subPath, params, method = "GET") {
19882
+ const sub = subPath.startsWith("/") ? subPath : "/" + subPath;
19883
+ const qs = buildQS(params);
19884
+ const hasQuery = Array.from(qs.keys()).length > 0;
19885
+ const url = `${BASE_URL}/api/logs/${encodeURIComponent(project)}${sub}${method === "GET" && hasQuery ? `?${qs}` : ""}`;
19599
19886
  const res = await fetch(url, {
19887
+ method,
19600
19888
  headers: {
19601
19889
  Authorization: `Bearer ${TOKEN}`,
19602
- "User-Agent": USER_AGENT,
19890
+ "User-Agent": USER_AGENT2,
19603
19891
  Accept: "application/json"
19604
19892
  },
19605
19893
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
@@ -19610,6 +19898,30 @@ async function logsRequest(project, params) {
19610
19898
  }
19611
19899
  return res.json();
19612
19900
  }
19901
+ async function apiRequest(method, path, body) {
19902
+ const url = `${BASE_URL}${path.startsWith("/") ? path : "/" + path}`;
19903
+ const res = await fetch(url, {
19904
+ method,
19905
+ headers: {
19906
+ Authorization: `Bearer ${TOKEN}`,
19907
+ "User-Agent": USER_AGENT2,
19908
+ Accept: "application/json",
19909
+ ...body !== undefined ? { "Content-Type": "application/json" } : {}
19910
+ },
19911
+ body: body !== undefined ? JSON.stringify(body) : undefined,
19912
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
19913
+ });
19914
+ const text = await res.text();
19915
+ let data = text;
19916
+ try {
19917
+ data = text.length > 0 ? JSON.parse(text) : undefined;
19918
+ } catch {}
19919
+ if (!res.ok) {
19920
+ const msg = data && data.error || data && data.message || text || `HTTP ${res.status}`;
19921
+ throw new Error(`Loguro API error ${res.status}: ${msg}`);
19922
+ }
19923
+ return data;
19924
+ }
19613
19925
  function jsonResult(data, summary) {
19614
19926
  const content = [];
19615
19927
  if (summary)
@@ -19661,7 +19973,10 @@ server.tool("query_logs", "Query logs from a Loguro project with filters. Use th
19661
19973
  perPage: exports_external.number().default(20).describe("Results per page (default 20, max 1000)"),
19662
19974
  cursor: exports_external.string().optional().describe("Pagination cursor from previous response"),
19663
19975
  offset: exports_external.number().optional().describe("Alternative to cursor: numeric offset"),
19664
- order: exports_external.enum(["asc", "desc"]).default("desc").describe("Sort order by timestamp")
19976
+ order: exports_external.enum(["asc", "desc"]).default("desc").describe("Sort order by timestamp"),
19977
+ fields: exports_external.array(exports_external.string()).optional().describe("Project only these top-level fields on each log (e.g. ['id','timestamp','level','message']). Omit for full payload. Applied client-side."),
19978
+ truncateContext: exports_external.boolean().default(true).describe(`Truncate long context values and known long top-level fields (${TRUNCATABLE_TOP_FIELDS.join(", ")}) to prevent LLM context overflow. Set false for raw payload.`),
19979
+ truncateLimit: exports_external.number().default(ENV_TRUNCATE_LIMIT).describe(`Char limit when truncateContext=true. Default ${ENV_TRUNCATE_LIMIT} (override via LOGURO_TRUNCATE_LIMIT env var).`)
19665
19980
  }, async (args) => {
19666
19981
  const params = {
19667
19982
  perPage: args.perPage,
@@ -19701,11 +20016,68 @@ server.tool("query_logs", "Query logs from a Loguro project with filters. Use th
19701
20016
  params["offset"] = args.offset;
19702
20017
  applyContextFilters(params, args.context);
19703
20018
  const data = await logsRequest(args.project, params);
20019
+ let truncated = 0;
20020
+ let projected = 0;
20021
+ const truncateStr = (s) => s.length > args.truncateLimit ? s.slice(0, args.truncateLimit) + `…[truncated ${s.length - args.truncateLimit}ch]` : s;
20022
+ if (Array.isArray(data.items)) {
20023
+ data.items = data.items.map((item) => {
20024
+ let out = item;
20025
+ if (args.truncateContext) {
20026
+ const patched = { ...out };
20027
+ for (const f of TRUNCATABLE_TOP_FIELDS) {
20028
+ const v = patched[f];
20029
+ if (typeof v === "string" && v.length > args.truncateLimit) {
20030
+ patched[f] = truncateStr(v);
20031
+ truncated++;
20032
+ }
20033
+ }
20034
+ if (patched.context && typeof patched.context === "object") {
20035
+ const newCtx = {};
20036
+ for (const [k, v] of Object.entries(patched.context)) {
20037
+ if (typeof v === "string" && v.length > args.truncateLimit) {
20038
+ newCtx[k] = truncateStr(v);
20039
+ truncated++;
20040
+ } else if (v && typeof v === "object") {
20041
+ const serialized = JSON.stringify(v);
20042
+ if (serialized.length > args.truncateLimit) {
20043
+ newCtx[k] = truncateStr(serialized);
20044
+ truncated++;
20045
+ } else {
20046
+ newCtx[k] = v;
20047
+ }
20048
+ } else {
20049
+ newCtx[k] = v;
20050
+ }
20051
+ }
20052
+ patched.context = newCtx;
20053
+ }
20054
+ out = patched;
20055
+ }
20056
+ if (args.fields?.length) {
20057
+ const projectedItem = {};
20058
+ for (const f of args.fields) {
20059
+ if (f in out)
20060
+ projectedItem[f] = out[f];
20061
+ }
20062
+ out = projectedItem;
20063
+ projected++;
20064
+ }
20065
+ return out;
20066
+ });
20067
+ }
20068
+ const meta = {};
20069
+ if (truncated > 0)
20070
+ meta["_truncatedFields"] = truncated;
20071
+ if (projected > 0)
20072
+ meta["_projectedItems"] = projected;
20073
+ if (args.fields?.length)
20074
+ meta["_fields"] = args.fields;
19704
20075
  const summary = [
19705
20076
  `Found ${data.items?.length ?? 0} logs${data.hasMorePages ? " (more available)" : ""}`,
19706
- data.nextCursor ? `nextCursor: ${data.nextCursor}` : null
20077
+ data.nextCursor ? `nextCursor: ${data.nextCursor}` : null,
20078
+ truncated > 0 ? `truncated ${truncated} long context values` : null
19707
20079
  ].filter(Boolean).join(" | ");
19708
- return jsonResult(data, summary);
20080
+ return jsonResult({ ...data, ...meta }, summary);
19709
20081
  });
19710
20082
  server.tool("get_log_timeline", "Get logs surrounding a specific log ID within a time window. Useful for understanding context around an error.", {
19711
20083
  project: projectParam(),
@@ -19772,5 +20144,476 @@ server.tool("sample_logs", "Get a random sample of logs, optionally filtered. Go
19772
20144
  const data = await logsRequest(project, params);
19773
20145
  return jsonResult(data);
19774
20146
  });
20147
+ var timeRangeSchema = {
20148
+ from: exports_external.string().optional().describe("Start of range (ISO 8601). Relative shortcuts must be resolved client-side."),
20149
+ to: exports_external.string().optional().describe("End of range (ISO 8601)")
20150
+ };
20151
+ var DEFAULT_DEPLOY_PATTERNS = [
20152
+ "Deployment completed",
20153
+ "deploy finished",
20154
+ "Migration ran",
20155
+ "schema updated",
20156
+ "Service started",
20157
+ "Server listening",
20158
+ "Build completed",
20159
+ "Release tagged"
20160
+ ];
20161
+ var ENV_DEPLOY_PATTERNS = (process.env.LOGURO_DEPLOY_PATTERNS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
20162
+ server.tool("deploy_markers", "Find deploy/migration/restart signals in logs via message pattern match. Useful as anchor points for compare_windows (before/after deploy). Default patterns cover common signals; override with `patterns` param or LOGURO_DEPLOY_PATTERNS env var.", {
20163
+ project: projectParam(),
20164
+ patterns: exports_external.array(exports_external.string()).optional().describe("Override default patterns. Each = substring match on message (OR). Defaults: Deployment completed, deploy finished, Migration ran, schema updated, Service started, Server listening, Build completed, Release tagged."),
20165
+ from: exports_external.string().optional().describe("Start of range (ISO 8601). Default: last 7 days."),
20166
+ to: exports_external.string().optional().describe("End of range (ISO 8601)"),
20167
+ limit: exports_external.number().default(50).describe("Max markers to return")
20168
+ }, async ({ project, patterns, from, to, limit }) => {
20169
+ const effectivePatterns = patterns?.length ? patterns : ENV_DEPLOY_PATTERNS.length ? ENV_DEPLOY_PATTERNS : DEFAULT_DEPLOY_PATTERNS;
20170
+ const params = {
20171
+ message: effectivePatterns,
20172
+ perPage: Math.min(limit, 100),
20173
+ order: "desc"
20174
+ };
20175
+ if (from)
20176
+ params["from"] = from;
20177
+ else
20178
+ params["from"] = new Date(Date.now() - 7 * 86400000).toISOString();
20179
+ if (to)
20180
+ params["to"] = to;
20181
+ const data = await logsRequest(project, params);
20182
+ const markers = (data.items ?? []).map((item) => {
20183
+ const message = String(item.message ?? "");
20184
+ const matchedPattern = effectivePatterns.find((p) => message.toLowerCase().includes(p.toLowerCase())) ?? null;
20185
+ return {
20186
+ id: item.id,
20187
+ timestamp: item.timestamp,
20188
+ level: item.level,
20189
+ message,
20190
+ matchedPattern,
20191
+ context: item.context ?? null
20192
+ };
20193
+ });
20194
+ const summary = markers.length === 0 ? `No deploy markers found (searched ${effectivePatterns.length} pattern(s))` : `Found ${markers.length} deploy marker(s) — latest: ${markers[0]?.timestamp ?? "?"} (${markers[0]?.matchedPattern ?? "?"})`;
20195
+ return jsonResult({
20196
+ markers,
20197
+ patternsUsed: effectivePatterns,
20198
+ patternSource: patterns?.length ? "param" : ENV_DEPLOY_PATTERNS.length ? "env" : "default",
20199
+ hasMore: data.hasMorePages ?? false
20200
+ }, summary);
20201
+ });
20202
+ server.tool("get_latest", "Fetch the single most recent log matching optional filters. Anchor point for 'when did X last happen?' queries. Returns { items: [log] } with at most 1 entry, plus nextCursor for paging back.", {
20203
+ project: projectParam(),
20204
+ level: exports_external.array(exports_external.string()).optional(),
20205
+ notLevel: exports_external.array(exports_external.string()).optional(),
20206
+ message: exports_external.array(exports_external.string()).optional(),
20207
+ notMessage: exports_external.array(exports_external.string()).optional(),
20208
+ search: exports_external.string().optional(),
20209
+ trace: exports_external.array(exports_external.string()).optional(),
20210
+ slow: exports_external.number().optional().describe("Latest log where context.duration > N ms"),
20211
+ context: contextFilterSchema
20212
+ }, async (args) => {
20213
+ const params = {
20214
+ perPage: 1,
20215
+ order: "desc"
20216
+ };
20217
+ if (args.level?.length)
20218
+ params["level"] = args.level;
20219
+ if (args.notLevel?.length)
20220
+ params["notLevel"] = args.notLevel;
20221
+ if (args.message?.length)
20222
+ params["message"] = args.message;
20223
+ if (args.notMessage?.length)
20224
+ params["notMessage"] = args.notMessage;
20225
+ if (args.search)
20226
+ params["search"] = args.search;
20227
+ if (args.trace?.length)
20228
+ params["trace"] = args.trace;
20229
+ if (args.slow !== undefined)
20230
+ params["slow"] = args.slow;
20231
+ applyContextFilters(params, args.context);
20232
+ const data = await logsRequest(args.project, params);
20233
+ const latest = data.items?.[0];
20234
+ const summary = latest ? `Latest: ${latest.level ?? "?"} @ ${latest.timestamp ?? "?"} — ${(latest.message ?? "").slice(0, 80)}` : "No logs matched filters";
20235
+ return jsonResult(data, summary);
20236
+ });
20237
+ server.tool("count_logs", "Get the count of logs matching filters. Cheap pre-flight before running large queries. Returns { count: number }.", {
20238
+ project: projectParam(),
20239
+ level: exports_external.array(exports_external.string()).optional(),
20240
+ notLevel: exports_external.array(exports_external.string()).optional(),
20241
+ message: exports_external.array(exports_external.string()).optional(),
20242
+ notMessage: exports_external.array(exports_external.string()).optional(),
20243
+ search: exports_external.string().optional(),
20244
+ ...timeRangeSchema,
20245
+ context: contextFilterSchema,
20246
+ unfiltered: exports_external.boolean().default(false).describe("If true, return total project count (ignores filters). Uses /count instead of /count-filtered.")
20247
+ }, async (args) => {
20248
+ if (args.unfiltered) {
20249
+ const data2 = await logsSubRequest(args.project, "/count", {}, "POST");
20250
+ return jsonResult(data2, `Total logs in project: ${data2.count}`);
20251
+ }
20252
+ const params = {};
20253
+ if (args.level?.length)
20254
+ params["level"] = args.level;
20255
+ if (args.notLevel?.length)
20256
+ params["notLevel"] = args.notLevel;
20257
+ if (args.message?.length)
20258
+ params["message"] = args.message;
20259
+ if (args.notMessage?.length)
20260
+ params["notMessage"] = args.notMessage;
20261
+ if (args.search)
20262
+ params["search"] = args.search;
20263
+ if (args.from)
20264
+ params["from"] = args.from;
20265
+ if (args.to)
20266
+ params["to"] = args.to;
20267
+ applyContextFilters(params, args.context);
20268
+ const data = await logsSubRequest(args.project, "/count-filtered", params);
20269
+ return jsonResult(data, `Matching logs: ${data.count}`);
20270
+ });
20271
+ server.tool("get_log", "Fetch a single log by its ULID. Returns the full log entry plus prev/next pointers for navigation.", {
20272
+ project: projectParam(),
20273
+ logId: exports_external.string().describe("ULID of the log")
20274
+ }, async ({ project, logId }) => {
20275
+ const data = await logsSubRequest(project, `/one/${encodeURIComponent(logId)}`, {});
20276
+ return jsonResult(data);
20277
+ });
20278
+ server.tool("expand_log", "Get a log with fully expanded context and related logs. Best tool for root cause analysis on a specific error.", {
20279
+ project: projectParam(),
20280
+ logId: exports_external.string().describe("ULID of the log")
20281
+ }, async ({ project, logId }) => {
20282
+ const data = await logsSubRequest(project, `/${encodeURIComponent(logId)}/expanded`, {});
20283
+ return jsonResult(data);
20284
+ });
20285
+ server.tool("log_occurrences", "How many times has this log message recurred? Returns timestamps of recent occurrences plus total count.", {
20286
+ project: projectParam(),
20287
+ logId: exports_external.string().describe("ULID of the log"),
20288
+ limit: exports_external.number().default(50).describe("Max occurrences to return")
20289
+ }, async ({ project, logId, limit }) => {
20290
+ const data = await logsSubRequest(project, `/${encodeURIComponent(logId)}/occurrences`, { limit });
20291
+ return jsonResult(data, `Total occurrences: ${data.total ?? data.occurrences?.length ?? 0}`);
20292
+ });
20293
+ server.tool("top_field", "Top values for a field ranked by count. Use to find top errors (field=message), top services, top users, etc.", {
20294
+ project: projectParam(),
20295
+ field: exports_external.string().describe("Field to rank: level, message, trace, or a context column name"),
20296
+ limit: exports_external.number().min(1).max(100).default(10),
20297
+ level: exports_external.array(exports_external.string()).optional(),
20298
+ ...timeRangeSchema
20299
+ }, async ({ project, field, limit, level, from, to }) => {
20300
+ const params = { field, limit };
20301
+ if (level?.length)
20302
+ params["level"] = level;
20303
+ if (from)
20304
+ params["from"] = from;
20305
+ if (to)
20306
+ params["to"] = to;
20307
+ const data = await logsSubRequest(project, "/top-field", params);
20308
+ return jsonResult(data);
20309
+ });
20310
+ server.tool("unique_field", "Distinct values for a field with counts. Similar to top_field but returns all unique values up to limit.", {
20311
+ project: projectParam(),
20312
+ field: exports_external.string(),
20313
+ limit: exports_external.number().default(50)
20314
+ }, async ({ project, field, limit }) => {
20315
+ const data = await logsSubRequest(project, "/unique-field", { field, limit });
20316
+ return jsonResult(data);
20317
+ });
20318
+ function parseBucketDateMs(date4) {
20319
+ if (date4.includes(" ") || date4.includes("T")) {
20320
+ return new Date(date4.replace(" ", "T") + (date4.endsWith("Z") ? "" : "Z")).getTime();
20321
+ }
20322
+ return new Date(date4 + "T00:00:00Z").getTime();
20323
+ }
20324
+ function rollupToHour(timeline) {
20325
+ const buckets = new Map;
20326
+ for (const entry of timeline) {
20327
+ const ms = parseBucketDateMs(entry.date);
20328
+ const hourMs = Math.floor(ms / 3600000) * 3600000;
20329
+ const acc = buckets.get(hourMs);
20330
+ if (acc) {
20331
+ acc.count += Number(entry.count) || 0;
20332
+ acc.error += Number(entry.error) || 0;
20333
+ acc.warning += Number(entry.warning) || 0;
20334
+ acc.info += Number(entry.info) || 0;
20335
+ acc.debug += Number(entry.debug) || 0;
20336
+ acc.critical += Number(entry.critical) || 0;
20337
+ } else {
20338
+ const iso = new Date(hourMs).toISOString();
20339
+ buckets.set(hourMs, {
20340
+ date: `${iso.slice(0, 13)}:00:00`,
20341
+ count: Number(entry.count) || 0,
20342
+ error: Number(entry.error) || 0,
20343
+ warning: Number(entry.warning) || 0,
20344
+ info: Number(entry.info) || 0,
20345
+ debug: Number(entry.debug) || 0,
20346
+ critical: Number(entry.critical) || 0
20347
+ });
20348
+ }
20349
+ }
20350
+ return [...buckets.entries()].sort((a, b) => b[0] - a[0]).map(([, v]) => v);
20351
+ }
20352
+ function deriveAggregatesFromTimeline(timeline) {
20353
+ const now = new Date;
20354
+ const todayUtc = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")}`;
20355
+ const y = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1));
20356
+ const yesterdayUtc = `${y.getUTCFullYear()}-${String(y.getUTCMonth() + 1).padStart(2, "0")}-${String(y.getUTCDate()).padStart(2, "0")}`;
20357
+ let totalCount = 0;
20358
+ let todayCount = 0;
20359
+ let yesterdayCount = 0;
20360
+ const levels = { error: 0, warning: 0, info: 0, debug: 0, critical: 0 };
20361
+ for (const e of timeline) {
20362
+ const count = Number(e.count) || 0;
20363
+ totalCount += count;
20364
+ const datePrefix = e.date.slice(0, 10);
20365
+ if (datePrefix === todayUtc)
20366
+ todayCount += count;
20367
+ else if (datePrefix === yesterdayUtc)
20368
+ yesterdayCount += count;
20369
+ levels.error += Number(e.error) || 0;
20370
+ levels.warning += Number(e.warning) || 0;
20371
+ levels.info += Number(e.info) || 0;
20372
+ levels.debug += Number(e.debug) || 0;
20373
+ levels.critical += Number(e.critical) || 0;
20374
+ }
20375
+ const byLevel = Object.entries(levels).filter(([, c]) => c > 0).map(([level, count]) => ({ level, count })).sort((a, b) => b.count - a.count);
20376
+ return { totalCount, todayCount, yesterdayCount, byLevel };
20377
+ }
20378
+ server.tool("histogram", "Time-bucketed log counts with level breakdown. day = daily timeline + full header aggregates (totalCount/byLevel/topErrors/topServices). hour = minute buckets rolled up to HH:00 client-side; totalCount + byLevel derived from timeline. minute = raw passthrough (~1440 buckets/day; max 24h range). For hour/minute, topErrors/topServices/topEndpoints require raw-log aggregates — use top_field with field='message'/'service'/'context.url' to fetch them separately.", {
20379
+ project: projectParam(),
20380
+ ...timeRangeSchema,
20381
+ granularity: exports_external.enum(["day", "hour", "minute"]).default("hour").describe("day = daily timeline. hour = rolled up to HH:00. minute = raw (~1440/day, narrow ranges only)."),
20382
+ level: exports_external.array(exports_external.string()).optional(),
20383
+ message: exports_external.array(exports_external.string()).optional()
20384
+ }, async ({ project, from, to, granularity, level, message }) => {
20385
+ if (granularity === "minute") {
20386
+ const fromMs = from ? new Date(from).getTime() : Date.now() - 24 * 3600000;
20387
+ const toMs = to ? new Date(to).getTime() : Date.now();
20388
+ const rangeHours = (toMs - fromMs) / 3600000;
20389
+ if (rangeHours > 24) {
20390
+ throw new Error(`Range too wide for granularity=minute (${rangeHours.toFixed(1)}h, max 24h). Use granularity=hour or narrow the range.`);
20391
+ }
20392
+ }
20393
+ const params = {};
20394
+ if (from)
20395
+ params["from"] = from;
20396
+ if (to)
20397
+ params["to"] = to;
20398
+ if (granularity !== "day")
20399
+ params["granularity"] = "hour";
20400
+ if (level?.length)
20401
+ params["level"] = level;
20402
+ if (message?.length)
20403
+ params["message"] = message;
20404
+ const data = await logsSubRequest(project, "/analytics", params);
20405
+ if (granularity === "day") {
20406
+ return jsonResult(data);
20407
+ }
20408
+ const rawTimeline = Array.isArray(data?.timeline) ? data.timeline : [];
20409
+ const timeline = granularity === "hour" ? rollupToHour(rawTimeline) : rawTimeline;
20410
+ const { totalCount, todayCount, yesterdayCount, byLevel } = deriveAggregatesFromTimeline(rawTimeline);
20411
+ return jsonResult({
20412
+ ...data,
20413
+ timeline,
20414
+ totalCount,
20415
+ todayCount,
20416
+ yesterdayCount,
20417
+ byLevel,
20418
+ topErrors: null,
20419
+ topServices: null,
20420
+ topEndpoints: null,
20421
+ _granularity: granularity,
20422
+ _aggregateSource: "derived-from-timeline",
20423
+ _aggregateLimitations: "topErrors/topServices/topEndpoints require raw-log aggregates — call top_field separately if needed",
20424
+ _rawBucketCount: rawTimeline.length
20425
+ });
20426
+ });
20427
+ server.tool("rate", "Logs-per-hour breakdown over a time range. Returns { buckets: [{ hour, count, ... }] }. Bucket size is fixed at 1 hour server-side. Use histogram with granularity=hour for level breakdown per bucket.", {
20428
+ project: projectParam(),
20429
+ ...timeRangeSchema,
20430
+ level: exports_external.array(exports_external.string()).optional(),
20431
+ notLevel: exports_external.array(exports_external.string()).optional(),
20432
+ message: exports_external.array(exports_external.string()).optional(),
20433
+ notMessage: exports_external.array(exports_external.string()).optional()
20434
+ }, async ({ project, from, to, level, notLevel, message, notMessage }) => {
20435
+ const params = {};
20436
+ if (from)
20437
+ params["from"] = from;
20438
+ if (to)
20439
+ params["to"] = to;
20440
+ if (level?.length)
20441
+ params["level"] = level;
20442
+ if (notLevel?.length)
20443
+ params["notLevel"] = notLevel;
20444
+ if (message?.length)
20445
+ params["message"] = message;
20446
+ if (notMessage?.length)
20447
+ params["notMessage"] = notMessage;
20448
+ const data = await logsSubRequest(project, "/rate", params);
20449
+ return jsonResult(data);
20450
+ });
20451
+ server.tool("health_trend", "Error-rate snapshot: last 20 minutes vs previous 20 minutes. Time window is fixed server-side. Returns raw { current, previous, totalCurrent, totalPrevious } where current/previous are error ratios (0..1). MCP-derived: status + trend.", {
20452
+ project: projectParam()
20453
+ }, async ({ project }) => {
20454
+ const data = await logsSubRequest(project, "/health-trend", {});
20455
+ const current = Number(data.current ?? 0);
20456
+ const previous = Number(data.previous ?? 0);
20457
+ const totalCurrent = Number(data.totalCurrent ?? 0);
20458
+ const totalPrevious = Number(data.totalPrevious ?? 0);
20459
+ let status;
20460
+ if (totalCurrent === 0 && totalPrevious === 0)
20461
+ status = "idle";
20462
+ else if (current >= 0.2)
20463
+ status = "critical";
20464
+ else if (current >= 0.05)
20465
+ status = "warning";
20466
+ else
20467
+ status = "healthy";
20468
+ let trend;
20469
+ const delta = current - previous;
20470
+ if (Math.abs(delta) < 0.01)
20471
+ trend = "stable";
20472
+ else
20473
+ trend = delta > 0 ? "degrading" : "improving";
20474
+ const enriched = {
20475
+ current,
20476
+ previous,
20477
+ totalCurrent,
20478
+ totalPrevious,
20479
+ errorRatePct: { current: +(current * 100).toFixed(2), previous: +(previous * 100).toFixed(2) },
20480
+ status,
20481
+ trend,
20482
+ window: "last 20 minutes vs previous 20 minutes (server-fixed)"
20483
+ };
20484
+ const summary = `Status: ${status} | Trend: ${trend} | Error rate: ${enriched.errorRatePct.current}% (was ${enriched.errorRatePct.previous}%) | Volume: ${totalCurrent} vs ${totalPrevious}`;
20485
+ return jsonResult(enriched, summary);
20486
+ });
20487
+ server.tool("compare_windows", "Diff two time windows. Highlights new, spiking, and dropped log patterns. Server parses windows with chrono — supply either a chrono phrase OR explicit ISO from/to pairs (preferred for precise sub-day ranges).", {
20488
+ project: projectParam(),
20489
+ baseline: exports_external.string().optional().describe("Baseline as chrono phrase: 'yesterday', '2 days ago', 'last week'. Omit if using baselineFrom/baselineTo."),
20490
+ baselineFrom: exports_external.string().optional().describe("Baseline start (ISO 8601 with timezone, e.g. 2026-05-12T19:00:00Z)"),
20491
+ baselineTo: exports_external.string().optional().describe("Baseline end (ISO 8601 with timezone)"),
20492
+ comparison: exports_external.string().optional().describe("Comparison as chrono phrase. Omit if using comparisonFrom/comparisonTo or defaulting to 'now'."),
20493
+ comparisonFrom: exports_external.string().optional().describe("Comparison start (ISO 8601 with timezone)"),
20494
+ comparisonTo: exports_external.string().optional().describe("Comparison end (ISO 8601 with timezone)"),
20495
+ spikeThreshold: exports_external.number().default(200).describe("% growth to flag as spike"),
20496
+ dropThreshold: exports_external.number().default(50).describe("% decline to flag as drop")
20497
+ }, async (args) => {
20498
+ const buildExpr = (phrase, from, to) => {
20499
+ if (phrase)
20500
+ return phrase;
20501
+ if (from && to)
20502
+ return `${from} to ${to}`;
20503
+ if (from || to) {
20504
+ throw new Error("ISO range requires both *From and *To (got only one).");
20505
+ }
20506
+ return;
20507
+ };
20508
+ const baselineExpr = buildExpr(args.baseline, args.baselineFrom, args.baselineTo);
20509
+ const comparisonExpr = buildExpr(args.comparison, args.comparisonFrom, args.comparisonTo);
20510
+ if (!baselineExpr) {
20511
+ throw new Error("baseline required: provide either `baseline` phrase or `baselineFrom`+`baselineTo` ISO pair.");
20512
+ }
20513
+ const params = {
20514
+ diff_baseline: baselineExpr,
20515
+ diff_spike_threshold: args.spikeThreshold,
20516
+ diff_drop_threshold: args.dropThreshold
20517
+ };
20518
+ if (comparisonExpr)
20519
+ params["diff_comparison"] = comparisonExpr;
20520
+ const data = await logsSubRequest(args.project, "/diff", params);
20521
+ const patterns = Array.isArray(data?.patterns) ? data.patterns : [];
20522
+ const newCount = patterns.filter((p) => p.status === "new").length;
20523
+ const spikeCount = patterns.filter((p) => p.status === "spike").length;
20524
+ const dropCount = patterns.filter((p) => p.status === "dropped").length;
20525
+ const baseTotal = Number(data?.baseline?.totalCount) || 0;
20526
+ const compTotal = Number(data?.comparison?.totalCount) || 0;
20527
+ const volumeDelta = baseTotal > 0 ? `${compTotal > baseTotal ? "+" : ""}${Math.round((compTotal - baseTotal) / baseTotal * 100)}%` : "n/a";
20528
+ const topChange = patterns[0];
20529
+ const topChangeStr = topChange ? ` | top: ${topChange.status} ${topChange.change} "${String(topChange.message).slice(0, 50)}"` : "";
20530
+ const summary = `Volume: ${baseTotal} → ${compTotal} (${volumeDelta}) | Patterns: ${newCount} new, ${spikeCount} spike, ${dropCount} dropped${topChangeStr}`;
20531
+ return jsonResult(data, summary);
20532
+ });
20533
+ server.tool("context_discovery", "List all context field keys present in this project with sample values. Call this FIRST when you don't know what fields exist.", {
20534
+ project: projectParam()
20535
+ }, async ({ project }) => {
20536
+ const data = await logsSubRequest(project, "/context-discovery", {});
20537
+ return jsonResult(data);
20538
+ });
20539
+ server.tool("context_field_values", "Sample values for a specific context field. Use after context_discovery to see what values a field actually contains.", {
20540
+ project: projectParam(),
20541
+ field: exports_external.string().describe("Context field key (without the context_ prefix)"),
20542
+ limit: exports_external.number().default(50)
20543
+ }, async ({ project, field, limit }) => {
20544
+ const data = await logsSubRequest(project, "/context-field-values", { field, limit });
20545
+ return jsonResult(data);
20546
+ });
20547
+ server.tool("list_projects", "List all projects the user has access to. Use when LOGURO_PROJECT is not set or the user references a project by name.", {}, async () => {
20548
+ const data = await apiRequest("GET", "/api/projects");
20549
+ const projects = (data.projects ?? []).map((p) => ({
20550
+ id: p.id,
20551
+ slug: p.slug,
20552
+ name: p.name,
20553
+ description: p.description
20554
+ }));
20555
+ return jsonResult({ projects }, `Found ${projects.length} project(s)`);
20556
+ });
20557
+ server.tool("get_investigation", "Fetch a cached AI investigation for a log fingerprint, if one exists. Call before running a new investigation to avoid duplicate work.", {
20558
+ projectId: exports_external.number().describe("Numeric project ID (use list_projects to resolve a slug to id)"),
20559
+ fingerprint: exports_external.string().describe("Log fingerprint (normalized message hash)")
20560
+ }, async ({ projectId, fingerprint }) => {
20561
+ const qs = new URLSearchParams({ projectId: String(projectId), fingerprint });
20562
+ const data = await apiRequest("GET", `/api/plugins/investigate?${qs}`);
20563
+ return jsonResult(data, data?.investigation ? "Cached investigation found" : "No cached investigation");
20564
+ });
20565
+ server.tool("check_investigated", "Batch check which fingerprints have cached AI investigations. Returns { investigated: string[] } — the subset with results.", {
20566
+ project: projectParam(),
20567
+ fingerprints: exports_external.array(exports_external.string()).min(1).describe("List of log fingerprints to check")
20568
+ }, async ({ project, fingerprints }) => {
20569
+ const url = `${BASE_URL}/api/logs/${encodeURIComponent(project)}/investigations`;
20570
+ const res = await fetch(url, {
20571
+ method: "POST",
20572
+ headers: {
20573
+ Authorization: `Bearer ${TOKEN}`,
20574
+ "User-Agent": USER_AGENT2,
20575
+ Accept: "application/json",
20576
+ "Content-Type": "application/json"
20577
+ },
20578
+ body: JSON.stringify({ fingerprints }),
20579
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
20580
+ });
20581
+ if (!res.ok) {
20582
+ throw new Error(`Loguro API error ${res.status}: ${await res.text()}`);
20583
+ }
20584
+ const data = await res.json();
20585
+ return jsonResult(data, `Cached investigations: ${data.investigated?.length ?? 0}/${fingerprints.length}`);
20586
+ });
20587
+ server.tool("list_docs", "Browse the Loguro documentation catalog. Returns titles + paths grouped by section. Call without args to see everything; pass `section` to filter. Use `read_doc` to fetch full content of a page.", {
20588
+ section: exports_external.string().optional().describe("Filter by section: General, CLI, Commands, Features, MCP, TUI, Web Analytics")
20589
+ }, async ({ section }) => {
20590
+ const docs = listDocs(section);
20591
+ const sections = listSections();
20592
+ const grouped = {};
20593
+ for (const d of docs) {
20594
+ if (!grouped[d.section])
20595
+ grouped[d.section] = [];
20596
+ grouped[d.section].push({ path: d.path, title: d.title, headings: d.headings });
20597
+ }
20598
+ const summary = section ? `${docs.length} page(s) in section "${section}"` : `${docs.length} pages across ${sections.length} sections: ${sections.map((s) => `${s.section} (${s.count})`).join(", ")}`;
20599
+ return jsonResult({ sections, docs: grouped }, summary);
20600
+ });
20601
+ server.tool("search_docs", "Search Loguro documentation by keyword. Fetches all docs on first call (cached 15min), then scores matches across title, section, headings, and body. Returns top hits with snippets. Use this when you don't know which page covers a topic.", {
20602
+ query: exports_external.string().min(1).describe("Search terms — natural words, not regex. E.g. 'how do alerts work', 'PAT token', 'context filter'."),
20603
+ limit: exports_external.number().min(1).max(20).default(5).describe("Max results (default 5)")
20604
+ }, async ({ query, limit }) => {
20605
+ const hits = await searchDocs(query, limit);
20606
+ const summary = hits.length === 0 ? `No matches for "${query}"` : `Top ${hits.length} hit(s) for "${query}" — best: ${hits[0].title} (${hits[0].section}) score=${hits[0].score}`;
20607
+ return jsonResult({ query, hits }, summary);
20608
+ });
20609
+ server.tool("read_doc", "Fetch the full markdown body of a documentation page by its path (e.g. '/docs/cli/alerts'). Use after `search_docs` or `list_docs` to read a specific page end-to-end.", {
20610
+ path: exports_external.string().describe("Doc path starting with /docs/ — e.g. /docs/mcp/setup"),
20611
+ refresh: exports_external.boolean().default(false).describe("Bypass the 15min cache and refetch from logu.ro")
20612
+ }, async ({ path, refresh }) => {
20613
+ const normalized = path.startsWith("/") ? path : "/" + path;
20614
+ const doc2 = await fetchDoc(normalized, refresh);
20615
+ const summary = `${normalized} (${doc2.body.length} chars, ${doc2.headings.length} heading(s))`;
20616
+ return jsonResult({ path: normalized, headings: doc2.headings, body: doc2.body, fetchedAt: new Date(doc2.fetchedAt).toISOString() }, summary);
20617
+ });
19775
20618
  var transport = new StdioServerTransport;
19776
20619
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loguro/mcp",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for Loguro — query logs from Claude Code, Cursor, and other MCP-compatible AI tools. Reuses the CLI's saved auth (zero config).",
5
5
  "keywords": [
6
6
  "loguro",