@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.
- package/dist/index.js +560 -5
- 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
|
-
|
|
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
|
-
|
|
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