@loguro/mcp 1.0.0 → 1.1.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 +560 -5
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -19577,13 +19577,21 @@ var cliAuth = loadCliAuth();
19577
19577
  var TOKEN = process.env.LOGURO_TOKEN ?? cliAuth.token;
19578
19578
  var BASE_URL = (process.env.LOGURO_BASE_URL ?? cliAuth.baseUrl ?? "https://logu.ro").replace(/\/$/, "");
19579
19579
  var DEFAULT_PROJECT = process.env.LOGURO_PROJECT;
19580
+ var ENV_TRUNCATE_LIMIT = (() => {
19581
+ const raw = process.env.LOGURO_TRUNCATE_LIMIT;
19582
+ if (!raw)
19583
+ return 500;
19584
+ const n = parseInt(raw, 10);
19585
+ return Number.isFinite(n) && n > 0 ? n : 500;
19586
+ })();
19587
+ var TRUNCATABLE_TOP_FIELDS = ["message", "stack", "stackTrace", "error", "errorMessage", "raw", "body"];
19580
19588
  if (!TOKEN) {
19581
19589
  process.stderr.write("Error: no PAT found. Run `loguro login` first, or set LOGURO_TOKEN env var.\n");
19582
19590
  process.exit(1);
19583
19591
  }
19584
19592
  var USER_AGENT = `loguro-mcp/1.0.0 (node/${process.version})`;
19585
19593
  var REQUEST_TIMEOUT_MS = 15000;
19586
- async function logsRequest(project, params) {
19594
+ function buildQS(params) {
19587
19595
  const qs = new URLSearchParams;
19588
19596
  for (const [key, value] of Object.entries(params)) {
19589
19597
  if (value === undefined || value === null)
@@ -19595,7 +19603,10 @@ async function logsRequest(project, params) {
19595
19603
  qs.set(key, String(value));
19596
19604
  }
19597
19605
  }
19598
- const url = `${BASE_URL}/api/logs/${encodeURIComponent(project)}?${qs}`;
19606
+ return qs;
19607
+ }
19608
+ async function logsRequest(project, params) {
19609
+ const url = `${BASE_URL}/api/logs/${encodeURIComponent(project)}?${buildQS(params)}`;
19599
19610
  const res = await fetch(url, {
19600
19611
  headers: {
19601
19612
  Authorization: `Bearer ${TOKEN}`,
@@ -19610,6 +19621,50 @@ async function logsRequest(project, params) {
19610
19621
  }
19611
19622
  return res.json();
19612
19623
  }
19624
+ async function logsSubRequest(project, subPath, params, method = "GET") {
19625
+ const sub = subPath.startsWith("/") ? subPath : "/" + subPath;
19626
+ const qs = buildQS(params);
19627
+ const hasQuery = Array.from(qs.keys()).length > 0;
19628
+ const url = `${BASE_URL}/api/logs/${encodeURIComponent(project)}${sub}${method === "GET" && hasQuery ? `?${qs}` : ""}`;
19629
+ const res = await fetch(url, {
19630
+ method,
19631
+ headers: {
19632
+ Authorization: `Bearer ${TOKEN}`,
19633
+ "User-Agent": USER_AGENT,
19634
+ Accept: "application/json"
19635
+ },
19636
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
19637
+ });
19638
+ if (!res.ok) {
19639
+ const text = await res.text();
19640
+ throw new Error(`Loguro API error ${res.status}: ${text}`);
19641
+ }
19642
+ return res.json();
19643
+ }
19644
+ async function apiRequest(method, path, body) {
19645
+ const url = `${BASE_URL}${path.startsWith("/") ? path : "/" + path}`;
19646
+ const res = await fetch(url, {
19647
+ method,
19648
+ headers: {
19649
+ Authorization: `Bearer ${TOKEN}`,
19650
+ "User-Agent": USER_AGENT,
19651
+ Accept: "application/json",
19652
+ ...body !== undefined ? { "Content-Type": "application/json" } : {}
19653
+ },
19654
+ body: body !== undefined ? JSON.stringify(body) : undefined,
19655
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
19656
+ });
19657
+ const text = await res.text();
19658
+ let data = text;
19659
+ try {
19660
+ data = text.length > 0 ? JSON.parse(text) : undefined;
19661
+ } catch {}
19662
+ if (!res.ok) {
19663
+ const msg = data && data.error || data && data.message || text || `HTTP ${res.status}`;
19664
+ throw new Error(`Loguro API error ${res.status}: ${msg}`);
19665
+ }
19666
+ return data;
19667
+ }
19613
19668
  function jsonResult(data, summary) {
19614
19669
  const content = [];
19615
19670
  if (summary)
@@ -19661,7 +19716,10 @@ server.tool("query_logs", "Query logs from a Loguro project with filters. Use th
19661
19716
  perPage: exports_external.number().default(20).describe("Results per page (default 20, max 1000)"),
19662
19717
  cursor: exports_external.string().optional().describe("Pagination cursor from previous response"),
19663
19718
  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")
19719
+ order: exports_external.enum(["asc", "desc"]).default("desc").describe("Sort order by timestamp"),
19720
+ 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."),
19721
+ 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.`),
19722
+ 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
19723
  }, async (args) => {
19666
19724
  const params = {
19667
19725
  perPage: args.perPage,
@@ -19701,11 +19759,68 @@ server.tool("query_logs", "Query logs from a Loguro project with filters. Use th
19701
19759
  params["offset"] = args.offset;
19702
19760
  applyContextFilters(params, args.context);
19703
19761
  const data = await logsRequest(args.project, params);
19762
+ let truncated = 0;
19763
+ let projected = 0;
19764
+ const truncateStr = (s) => s.length > args.truncateLimit ? s.slice(0, args.truncateLimit) + `…[truncated ${s.length - args.truncateLimit}ch]` : s;
19765
+ if (Array.isArray(data.items)) {
19766
+ data.items = data.items.map((item) => {
19767
+ let out = item;
19768
+ if (args.truncateContext) {
19769
+ const patched = { ...out };
19770
+ for (const f of TRUNCATABLE_TOP_FIELDS) {
19771
+ const v = patched[f];
19772
+ if (typeof v === "string" && v.length > args.truncateLimit) {
19773
+ patched[f] = truncateStr(v);
19774
+ truncated++;
19775
+ }
19776
+ }
19777
+ if (patched.context && typeof patched.context === "object") {
19778
+ const newCtx = {};
19779
+ for (const [k, v] of Object.entries(patched.context)) {
19780
+ if (typeof v === "string" && v.length > args.truncateLimit) {
19781
+ newCtx[k] = truncateStr(v);
19782
+ truncated++;
19783
+ } else if (v && typeof v === "object") {
19784
+ const serialized = JSON.stringify(v);
19785
+ if (serialized.length > args.truncateLimit) {
19786
+ newCtx[k] = truncateStr(serialized);
19787
+ truncated++;
19788
+ } else {
19789
+ newCtx[k] = v;
19790
+ }
19791
+ } else {
19792
+ newCtx[k] = v;
19793
+ }
19794
+ }
19795
+ patched.context = newCtx;
19796
+ }
19797
+ out = patched;
19798
+ }
19799
+ if (args.fields?.length) {
19800
+ const projectedItem = {};
19801
+ for (const f of args.fields) {
19802
+ if (f in out)
19803
+ projectedItem[f] = out[f];
19804
+ }
19805
+ out = projectedItem;
19806
+ projected++;
19807
+ }
19808
+ return out;
19809
+ });
19810
+ }
19811
+ const meta = {};
19812
+ if (truncated > 0)
19813
+ meta["_truncatedFields"] = truncated;
19814
+ if (projected > 0)
19815
+ meta["_projectedItems"] = projected;
19816
+ if (args.fields?.length)
19817
+ meta["_fields"] = args.fields;
19704
19818
  const summary = [
19705
19819
  `Found ${data.items?.length ?? 0} logs${data.hasMorePages ? " (more available)" : ""}`,
19706
- data.nextCursor ? `nextCursor: ${data.nextCursor}` : null
19820
+ data.nextCursor ? `nextCursor: ${data.nextCursor}` : null,
19821
+ truncated > 0 ? `truncated ${truncated} long context values` : null
19707
19822
  ].filter(Boolean).join(" | ");
19708
- return jsonResult(data, summary);
19823
+ return jsonResult({ ...data, ...meta }, summary);
19709
19824
  });
19710
19825
  server.tool("get_log_timeline", "Get logs surrounding a specific log ID within a time window. Useful for understanding context around an error.", {
19711
19826
  project: projectParam(),
@@ -19772,5 +19887,445 @@ server.tool("sample_logs", "Get a random sample of logs, optionally filtered. Go
19772
19887
  const data = await logsRequest(project, params);
19773
19888
  return jsonResult(data);
19774
19889
  });
19890
+ var timeRangeSchema = {
19891
+ from: exports_external.string().optional().describe("Start of range (ISO 8601). Relative shortcuts must be resolved client-side."),
19892
+ to: exports_external.string().optional().describe("End of range (ISO 8601)")
19893
+ };
19894
+ var DEFAULT_DEPLOY_PATTERNS = [
19895
+ "Deployment completed",
19896
+ "deploy finished",
19897
+ "Migration ran",
19898
+ "schema updated",
19899
+ "Service started",
19900
+ "Server listening",
19901
+ "Build completed",
19902
+ "Release tagged"
19903
+ ];
19904
+ var ENV_DEPLOY_PATTERNS = (process.env.LOGURO_DEPLOY_PATTERNS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
19905
+ 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.", {
19906
+ project: projectParam(),
19907
+ 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."),
19908
+ from: exports_external.string().optional().describe("Start of range (ISO 8601). Default: last 7 days."),
19909
+ to: exports_external.string().optional().describe("End of range (ISO 8601)"),
19910
+ limit: exports_external.number().default(50).describe("Max markers to return")
19911
+ }, async ({ project, patterns, from, to, limit }) => {
19912
+ const effectivePatterns = patterns?.length ? patterns : ENV_DEPLOY_PATTERNS.length ? ENV_DEPLOY_PATTERNS : DEFAULT_DEPLOY_PATTERNS;
19913
+ const params = {
19914
+ message: effectivePatterns,
19915
+ perPage: Math.min(limit, 100),
19916
+ order: "desc"
19917
+ };
19918
+ if (from)
19919
+ params["from"] = from;
19920
+ else
19921
+ params["from"] = new Date(Date.now() - 7 * 86400000).toISOString();
19922
+ if (to)
19923
+ params["to"] = to;
19924
+ const data = await logsRequest(project, params);
19925
+ const markers = (data.items ?? []).map((item) => {
19926
+ const message = String(item.message ?? "");
19927
+ const matchedPattern = effectivePatterns.find((p) => message.toLowerCase().includes(p.toLowerCase())) ?? null;
19928
+ return {
19929
+ id: item.id,
19930
+ timestamp: item.timestamp,
19931
+ level: item.level,
19932
+ message,
19933
+ matchedPattern,
19934
+ context: item.context ?? null
19935
+ };
19936
+ });
19937
+ 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 ?? "?"})`;
19938
+ return jsonResult({
19939
+ markers,
19940
+ patternsUsed: effectivePatterns,
19941
+ patternSource: patterns?.length ? "param" : ENV_DEPLOY_PATTERNS.length ? "env" : "default",
19942
+ hasMore: data.hasMorePages ?? false
19943
+ }, summary);
19944
+ });
19945
+ 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.", {
19946
+ project: projectParam(),
19947
+ level: exports_external.array(exports_external.string()).optional(),
19948
+ notLevel: exports_external.array(exports_external.string()).optional(),
19949
+ message: exports_external.array(exports_external.string()).optional(),
19950
+ notMessage: exports_external.array(exports_external.string()).optional(),
19951
+ search: exports_external.string().optional(),
19952
+ trace: exports_external.array(exports_external.string()).optional(),
19953
+ slow: exports_external.number().optional().describe("Latest log where context.duration > N ms"),
19954
+ context: contextFilterSchema
19955
+ }, async (args) => {
19956
+ const params = {
19957
+ perPage: 1,
19958
+ order: "desc"
19959
+ };
19960
+ if (args.level?.length)
19961
+ params["level"] = args.level;
19962
+ if (args.notLevel?.length)
19963
+ params["notLevel"] = args.notLevel;
19964
+ if (args.message?.length)
19965
+ params["message"] = args.message;
19966
+ if (args.notMessage?.length)
19967
+ params["notMessage"] = args.notMessage;
19968
+ if (args.search)
19969
+ params["search"] = args.search;
19970
+ if (args.trace?.length)
19971
+ params["trace"] = args.trace;
19972
+ if (args.slow !== undefined)
19973
+ params["slow"] = args.slow;
19974
+ applyContextFilters(params, args.context);
19975
+ const data = await logsRequest(args.project, params);
19976
+ const latest = data.items?.[0];
19977
+ const summary = latest ? `Latest: ${latest.level ?? "?"} @ ${latest.timestamp ?? "?"} — ${(latest.message ?? "").slice(0, 80)}` : "No logs matched filters";
19978
+ return jsonResult(data, summary);
19979
+ });
19980
+ server.tool("count_logs", "Get the count of logs matching filters. Cheap pre-flight before running large queries. Returns { count: number }.", {
19981
+ project: projectParam(),
19982
+ level: exports_external.array(exports_external.string()).optional(),
19983
+ notLevel: exports_external.array(exports_external.string()).optional(),
19984
+ message: exports_external.array(exports_external.string()).optional(),
19985
+ notMessage: exports_external.array(exports_external.string()).optional(),
19986
+ search: exports_external.string().optional(),
19987
+ ...timeRangeSchema,
19988
+ context: contextFilterSchema,
19989
+ unfiltered: exports_external.boolean().default(false).describe("If true, return total project count (ignores filters). Uses /count instead of /count-filtered.")
19990
+ }, async (args) => {
19991
+ if (args.unfiltered) {
19992
+ const data2 = await logsSubRequest(args.project, "/count", {}, "POST");
19993
+ return jsonResult(data2, `Total logs in project: ${data2.count}`);
19994
+ }
19995
+ const params = {};
19996
+ if (args.level?.length)
19997
+ params["level"] = args.level;
19998
+ if (args.notLevel?.length)
19999
+ params["notLevel"] = args.notLevel;
20000
+ if (args.message?.length)
20001
+ params["message"] = args.message;
20002
+ if (args.notMessage?.length)
20003
+ params["notMessage"] = args.notMessage;
20004
+ if (args.search)
20005
+ params["search"] = args.search;
20006
+ if (args.from)
20007
+ params["from"] = args.from;
20008
+ if (args.to)
20009
+ params["to"] = args.to;
20010
+ applyContextFilters(params, args.context);
20011
+ const data = await logsSubRequest(args.project, "/count-filtered", params);
20012
+ return jsonResult(data, `Matching logs: ${data.count}`);
20013
+ });
20014
+ server.tool("get_log", "Fetch a single log by its ULID. Returns the full log entry plus prev/next pointers for navigation.", {
20015
+ project: projectParam(),
20016
+ logId: exports_external.string().describe("ULID of the log")
20017
+ }, async ({ project, logId }) => {
20018
+ const data = await logsSubRequest(project, `/one/${encodeURIComponent(logId)}`, {});
20019
+ return jsonResult(data);
20020
+ });
20021
+ server.tool("expand_log", "Get a log with fully expanded context and related logs. Best tool for root cause analysis on a specific error.", {
20022
+ project: projectParam(),
20023
+ logId: exports_external.string().describe("ULID of the log")
20024
+ }, async ({ project, logId }) => {
20025
+ const data = await logsSubRequest(project, `/${encodeURIComponent(logId)}/expanded`, {});
20026
+ return jsonResult(data);
20027
+ });
20028
+ server.tool("log_occurrences", "How many times has this log message recurred? Returns timestamps of recent occurrences plus total count.", {
20029
+ project: projectParam(),
20030
+ logId: exports_external.string().describe("ULID of the log"),
20031
+ limit: exports_external.number().default(50).describe("Max occurrences to return")
20032
+ }, async ({ project, logId, limit }) => {
20033
+ const data = await logsSubRequest(project, `/${encodeURIComponent(logId)}/occurrences`, { limit });
20034
+ return jsonResult(data, `Total occurrences: ${data.total ?? data.occurrences?.length ?? 0}`);
20035
+ });
20036
+ server.tool("top_field", "Top values for a field ranked by count. Use to find top errors (field=message), top services, top users, etc.", {
20037
+ project: projectParam(),
20038
+ field: exports_external.string().describe("Field to rank: level, message, trace, or a context column name"),
20039
+ limit: exports_external.number().min(1).max(100).default(10),
20040
+ level: exports_external.array(exports_external.string()).optional(),
20041
+ ...timeRangeSchema
20042
+ }, async ({ project, field, limit, level, from, to }) => {
20043
+ const params = { field, limit };
20044
+ if (level?.length)
20045
+ params["level"] = level;
20046
+ if (from)
20047
+ params["from"] = from;
20048
+ if (to)
20049
+ params["to"] = to;
20050
+ const data = await logsSubRequest(project, "/top-field", params);
20051
+ return jsonResult(data);
20052
+ });
20053
+ server.tool("unique_field", "Distinct values for a field with counts. Similar to top_field but returns all unique values up to limit.", {
20054
+ project: projectParam(),
20055
+ field: exports_external.string(),
20056
+ limit: exports_external.number().default(50)
20057
+ }, async ({ project, field, limit }) => {
20058
+ const data = await logsSubRequest(project, "/unique-field", { field, limit });
20059
+ return jsonResult(data);
20060
+ });
20061
+ function parseBucketDateMs(date4) {
20062
+ if (date4.includes(" ") || date4.includes("T")) {
20063
+ return new Date(date4.replace(" ", "T") + (date4.endsWith("Z") ? "" : "Z")).getTime();
20064
+ }
20065
+ return new Date(date4 + "T00:00:00Z").getTime();
20066
+ }
20067
+ function rollupToHour(timeline) {
20068
+ const buckets = new Map;
20069
+ for (const entry of timeline) {
20070
+ const ms = parseBucketDateMs(entry.date);
20071
+ const hourMs = Math.floor(ms / 3600000) * 3600000;
20072
+ const acc = buckets.get(hourMs);
20073
+ if (acc) {
20074
+ acc.count += Number(entry.count) || 0;
20075
+ acc.error += Number(entry.error) || 0;
20076
+ acc.warning += Number(entry.warning) || 0;
20077
+ acc.info += Number(entry.info) || 0;
20078
+ acc.debug += Number(entry.debug) || 0;
20079
+ acc.critical += Number(entry.critical) || 0;
20080
+ } else {
20081
+ const iso = new Date(hourMs).toISOString();
20082
+ buckets.set(hourMs, {
20083
+ date: `${iso.slice(0, 13)}:00:00`,
20084
+ count: Number(entry.count) || 0,
20085
+ error: Number(entry.error) || 0,
20086
+ warning: Number(entry.warning) || 0,
20087
+ info: Number(entry.info) || 0,
20088
+ debug: Number(entry.debug) || 0,
20089
+ critical: Number(entry.critical) || 0
20090
+ });
20091
+ }
20092
+ }
20093
+ return [...buckets.entries()].sort((a, b) => b[0] - a[0]).map(([, v]) => v);
20094
+ }
20095
+ function deriveAggregatesFromTimeline(timeline) {
20096
+ const now = new Date;
20097
+ const todayUtc = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")}`;
20098
+ const y = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1));
20099
+ const yesterdayUtc = `${y.getUTCFullYear()}-${String(y.getUTCMonth() + 1).padStart(2, "0")}-${String(y.getUTCDate()).padStart(2, "0")}`;
20100
+ let totalCount = 0;
20101
+ let todayCount = 0;
20102
+ let yesterdayCount = 0;
20103
+ const levels = { error: 0, warning: 0, info: 0, debug: 0, critical: 0 };
20104
+ for (const e of timeline) {
20105
+ const count = Number(e.count) || 0;
20106
+ totalCount += count;
20107
+ const datePrefix = e.date.slice(0, 10);
20108
+ if (datePrefix === todayUtc)
20109
+ todayCount += count;
20110
+ else if (datePrefix === yesterdayUtc)
20111
+ yesterdayCount += count;
20112
+ levels.error += Number(e.error) || 0;
20113
+ levels.warning += Number(e.warning) || 0;
20114
+ levels.info += Number(e.info) || 0;
20115
+ levels.debug += Number(e.debug) || 0;
20116
+ levels.critical += Number(e.critical) || 0;
20117
+ }
20118
+ const byLevel = Object.entries(levels).filter(([, c]) => c > 0).map(([level, count]) => ({ level, count })).sort((a, b) => b.count - a.count);
20119
+ return { totalCount, todayCount, yesterdayCount, byLevel };
20120
+ }
20121
+ 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.", {
20122
+ project: projectParam(),
20123
+ ...timeRangeSchema,
20124
+ 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)."),
20125
+ level: exports_external.array(exports_external.string()).optional(),
20126
+ message: exports_external.array(exports_external.string()).optional()
20127
+ }, async ({ project, from, to, granularity, level, message }) => {
20128
+ if (granularity === "minute") {
20129
+ const fromMs = from ? new Date(from).getTime() : Date.now() - 24 * 3600000;
20130
+ const toMs = to ? new Date(to).getTime() : Date.now();
20131
+ const rangeHours = (toMs - fromMs) / 3600000;
20132
+ if (rangeHours > 24) {
20133
+ throw new Error(`Range too wide for granularity=minute (${rangeHours.toFixed(1)}h, max 24h). Use granularity=hour or narrow the range.`);
20134
+ }
20135
+ }
20136
+ const params = {};
20137
+ if (from)
20138
+ params["from"] = from;
20139
+ if (to)
20140
+ params["to"] = to;
20141
+ if (granularity !== "day")
20142
+ params["granularity"] = "hour";
20143
+ if (level?.length)
20144
+ params["level"] = level;
20145
+ if (message?.length)
20146
+ params["message"] = message;
20147
+ const data = await logsSubRequest(project, "/analytics", params);
20148
+ if (granularity === "day") {
20149
+ return jsonResult(data);
20150
+ }
20151
+ const rawTimeline = Array.isArray(data?.timeline) ? data.timeline : [];
20152
+ const timeline = granularity === "hour" ? rollupToHour(rawTimeline) : rawTimeline;
20153
+ const { totalCount, todayCount, yesterdayCount, byLevel } = deriveAggregatesFromTimeline(rawTimeline);
20154
+ return jsonResult({
20155
+ ...data,
20156
+ timeline,
20157
+ totalCount,
20158
+ todayCount,
20159
+ yesterdayCount,
20160
+ byLevel,
20161
+ topErrors: null,
20162
+ topServices: null,
20163
+ topEndpoints: null,
20164
+ _granularity: granularity,
20165
+ _aggregateSource: "derived-from-timeline",
20166
+ _aggregateLimitations: "topErrors/topServices/topEndpoints require raw-log aggregates — call top_field separately if needed",
20167
+ _rawBucketCount: rawTimeline.length
20168
+ });
20169
+ });
20170
+ 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.", {
20171
+ project: projectParam(),
20172
+ ...timeRangeSchema,
20173
+ level: exports_external.array(exports_external.string()).optional(),
20174
+ notLevel: exports_external.array(exports_external.string()).optional(),
20175
+ message: exports_external.array(exports_external.string()).optional(),
20176
+ notMessage: exports_external.array(exports_external.string()).optional()
20177
+ }, async ({ project, from, to, level, notLevel, message, notMessage }) => {
20178
+ const params = {};
20179
+ if (from)
20180
+ params["from"] = from;
20181
+ if (to)
20182
+ params["to"] = to;
20183
+ if (level?.length)
20184
+ params["level"] = level;
20185
+ if (notLevel?.length)
20186
+ params["notLevel"] = notLevel;
20187
+ if (message?.length)
20188
+ params["message"] = message;
20189
+ if (notMessage?.length)
20190
+ params["notMessage"] = notMessage;
20191
+ const data = await logsSubRequest(project, "/rate", params);
20192
+ return jsonResult(data);
20193
+ });
20194
+ 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.", {
20195
+ project: projectParam()
20196
+ }, async ({ project }) => {
20197
+ const data = await logsSubRequest(project, "/health-trend", {});
20198
+ const current = Number(data.current ?? 0);
20199
+ const previous = Number(data.previous ?? 0);
20200
+ const totalCurrent = Number(data.totalCurrent ?? 0);
20201
+ const totalPrevious = Number(data.totalPrevious ?? 0);
20202
+ let status;
20203
+ if (totalCurrent === 0 && totalPrevious === 0)
20204
+ status = "idle";
20205
+ else if (current >= 0.2)
20206
+ status = "critical";
20207
+ else if (current >= 0.05)
20208
+ status = "warning";
20209
+ else
20210
+ status = "healthy";
20211
+ let trend;
20212
+ const delta = current - previous;
20213
+ if (Math.abs(delta) < 0.01)
20214
+ trend = "stable";
20215
+ else
20216
+ trend = delta > 0 ? "degrading" : "improving";
20217
+ const enriched = {
20218
+ current,
20219
+ previous,
20220
+ totalCurrent,
20221
+ totalPrevious,
20222
+ errorRatePct: { current: +(current * 100).toFixed(2), previous: +(previous * 100).toFixed(2) },
20223
+ status,
20224
+ trend,
20225
+ window: "last 20 minutes vs previous 20 minutes (server-fixed)"
20226
+ };
20227
+ const summary = `Status: ${status} | Trend: ${trend} | Error rate: ${enriched.errorRatePct.current}% (was ${enriched.errorRatePct.previous}%) | Volume: ${totalCurrent} vs ${totalPrevious}`;
20228
+ return jsonResult(enriched, summary);
20229
+ });
20230
+ 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).", {
20231
+ project: projectParam(),
20232
+ baseline: exports_external.string().optional().describe("Baseline as chrono phrase: 'yesterday', '2 days ago', 'last week'. Omit if using baselineFrom/baselineTo."),
20233
+ baselineFrom: exports_external.string().optional().describe("Baseline start (ISO 8601 with timezone, e.g. 2026-05-12T19:00:00Z)"),
20234
+ baselineTo: exports_external.string().optional().describe("Baseline end (ISO 8601 with timezone)"),
20235
+ comparison: exports_external.string().optional().describe("Comparison as chrono phrase. Omit if using comparisonFrom/comparisonTo or defaulting to 'now'."),
20236
+ comparisonFrom: exports_external.string().optional().describe("Comparison start (ISO 8601 with timezone)"),
20237
+ comparisonTo: exports_external.string().optional().describe("Comparison end (ISO 8601 with timezone)"),
20238
+ spikeThreshold: exports_external.number().default(200).describe("% growth to flag as spike"),
20239
+ dropThreshold: exports_external.number().default(50).describe("% decline to flag as drop")
20240
+ }, async (args) => {
20241
+ const buildExpr = (phrase, from, to) => {
20242
+ if (phrase)
20243
+ return phrase;
20244
+ if (from && to)
20245
+ return `${from} to ${to}`;
20246
+ if (from || to) {
20247
+ throw new Error("ISO range requires both *From and *To (got only one).");
20248
+ }
20249
+ return;
20250
+ };
20251
+ const baselineExpr = buildExpr(args.baseline, args.baselineFrom, args.baselineTo);
20252
+ const comparisonExpr = buildExpr(args.comparison, args.comparisonFrom, args.comparisonTo);
20253
+ if (!baselineExpr) {
20254
+ throw new Error("baseline required: provide either `baseline` phrase or `baselineFrom`+`baselineTo` ISO pair.");
20255
+ }
20256
+ const params = {
20257
+ diff_baseline: baselineExpr,
20258
+ diff_spike_threshold: args.spikeThreshold,
20259
+ diff_drop_threshold: args.dropThreshold
20260
+ };
20261
+ if (comparisonExpr)
20262
+ params["diff_comparison"] = comparisonExpr;
20263
+ const data = await logsSubRequest(args.project, "/diff", params);
20264
+ const patterns = Array.isArray(data?.patterns) ? data.patterns : [];
20265
+ const newCount = patterns.filter((p) => p.status === "new").length;
20266
+ const spikeCount = patterns.filter((p) => p.status === "spike").length;
20267
+ const dropCount = patterns.filter((p) => p.status === "dropped").length;
20268
+ const baseTotal = Number(data?.baseline?.totalCount) || 0;
20269
+ const compTotal = Number(data?.comparison?.totalCount) || 0;
20270
+ const volumeDelta = baseTotal > 0 ? `${compTotal > baseTotal ? "+" : ""}${Math.round((compTotal - baseTotal) / baseTotal * 100)}%` : "n/a";
20271
+ const topChange = patterns[0];
20272
+ const topChangeStr = topChange ? ` | top: ${topChange.status} ${topChange.change} "${String(topChange.message).slice(0, 50)}"` : "";
20273
+ const summary = `Volume: ${baseTotal} → ${compTotal} (${volumeDelta}) | Patterns: ${newCount} new, ${spikeCount} spike, ${dropCount} dropped${topChangeStr}`;
20274
+ return jsonResult(data, summary);
20275
+ });
20276
+ 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.", {
20277
+ project: projectParam()
20278
+ }, async ({ project }) => {
20279
+ const data = await logsSubRequest(project, "/context-discovery", {});
20280
+ return jsonResult(data);
20281
+ });
20282
+ server.tool("context_field_values", "Sample values for a specific context field. Use after context_discovery to see what values a field actually contains.", {
20283
+ project: projectParam(),
20284
+ field: exports_external.string().describe("Context field key (without the context_ prefix)"),
20285
+ limit: exports_external.number().default(50)
20286
+ }, async ({ project, field, limit }) => {
20287
+ const data = await logsSubRequest(project, "/context-field-values", { field, limit });
20288
+ return jsonResult(data);
20289
+ });
20290
+ 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 () => {
20291
+ const data = await apiRequest("GET", "/api/projects");
20292
+ const projects = (data.projects ?? []).map((p) => ({
20293
+ id: p.id,
20294
+ slug: p.slug,
20295
+ name: p.name,
20296
+ description: p.description
20297
+ }));
20298
+ return jsonResult({ projects }, `Found ${projects.length} project(s)`);
20299
+ });
20300
+ 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.", {
20301
+ projectId: exports_external.number().describe("Numeric project ID (use list_projects to resolve a slug to id)"),
20302
+ fingerprint: exports_external.string().describe("Log fingerprint (normalized message hash)")
20303
+ }, async ({ projectId, fingerprint }) => {
20304
+ const qs = new URLSearchParams({ projectId: String(projectId), fingerprint });
20305
+ const data = await apiRequest("GET", `/api/plugins/investigate?${qs}`);
20306
+ return jsonResult(data, data?.investigation ? "Cached investigation found" : "No cached investigation");
20307
+ });
20308
+ server.tool("check_investigated", "Batch check which fingerprints have cached AI investigations. Returns { investigated: string[] } — the subset with results.", {
20309
+ project: projectParam(),
20310
+ fingerprints: exports_external.array(exports_external.string()).min(1).describe("List of log fingerprints to check")
20311
+ }, async ({ project, fingerprints }) => {
20312
+ const url = `${BASE_URL}/api/logs/${encodeURIComponent(project)}/investigations`;
20313
+ const res = await fetch(url, {
20314
+ method: "POST",
20315
+ headers: {
20316
+ Authorization: `Bearer ${TOKEN}`,
20317
+ "User-Agent": USER_AGENT,
20318
+ Accept: "application/json",
20319
+ "Content-Type": "application/json"
20320
+ },
20321
+ body: JSON.stringify({ fingerprints }),
20322
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
20323
+ });
20324
+ if (!res.ok) {
20325
+ throw new Error(`Loguro API error ${res.status}: ${await res.text()}`);
20326
+ }
20327
+ const data = await res.json();
20328
+ return jsonResult(data, `Cached investigations: ${data.investigated?.length ?? 0}/${fingerprints.length}`);
20329
+ });
19775
20330
  var transport = new StdioServerTransport;
19776
20331
  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.1.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",