@mokoconsulting/mokologs-mcp 4.1.0 → 4.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.
- package/CODE_OF_CONDUCT.md +1 -1
- package/CONTRIBUTING.md +1 -1
- package/Makefile +1 -1
- package/SECURITY.md +1 -1
- package/package.json +1 -1
- package/source/config.ts +5 -0
- package/source/reader.ts +33 -0
- package/source/winlog.ts +177 -0
package/CODE_OF_CONDUCT.md
CHANGED
package/CONTRIBUTING.md
CHANGED
package/Makefile
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# Identity (auto-filled by workflow sync — the Makefile is the version source of truth)
|
|
11
11
|
# ==============================================================================
|
|
12
12
|
PROJECT_NAME := mokologs-mcp
|
|
13
|
-
PROJECT_VERSION := 04.
|
|
13
|
+
PROJECT_VERSION := 04.02.00
|
|
14
14
|
PLATFORM := npm
|
|
15
15
|
|
|
16
16
|
# ==============================================================================
|
package/SECURITY.md
CHANGED
|
@@ -23,7 +23,7 @@ DEFGROUP: Template-Generic
|
|
|
23
23
|
INGROUP: Template-Generic.Documentation
|
|
24
24
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
|
25
25
|
PATH: /SECURITY.md
|
|
26
|
-
VERSION: 04.
|
|
26
|
+
VERSION: 04.02.00
|
|
27
27
|
BRIEF: Security vulnerability reporting and handling policy
|
|
28
28
|
-->
|
|
29
29
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mokoconsulting/mokologs-mcp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "MCP server for multi-source log triage & analysis — discover, tail, search, and assess logs across local and remote sources",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
package/source/config.ts
CHANGED
|
@@ -70,6 +70,11 @@ const SourceSchema = z.object({
|
|
|
70
70
|
})
|
|
71
71
|
.refine((s) => s.type !== 'gitea' || !!s.baseUrl, {
|
|
72
72
|
message: 'a "gitea" source requires "baseUrl"',
|
|
73
|
+
})
|
|
74
|
+
// A `windows` source reads the LOCAL Event Log in this version (no remote transport).
|
|
75
|
+
// Its `logDirs` are interpreted as Event Log channel names (e.g. ["System","Application"]).
|
|
76
|
+
.refine((s) => s.type !== 'windows' || !s.ssh, {
|
|
77
|
+
message: 'a "windows" source is local-only in this version (no "ssh" block); put channel names in "logDirs"',
|
|
73
78
|
});
|
|
74
79
|
|
|
75
80
|
const RedactionSchema = z
|
package/source/reader.ts
CHANGED
|
@@ -22,12 +22,16 @@ import { gunzipSync } from 'node:zlib';
|
|
|
22
22
|
import { dirname, basename, join } from 'node:path';
|
|
23
23
|
import { Client } from 'ssh2';
|
|
24
24
|
import type { LogSource, ReadResult, RedactionPolicy } from './types.js';
|
|
25
|
+
import { winListLogs, winFetchEvents, winNewestRecordId } from './winlog.js';
|
|
25
26
|
|
|
26
27
|
export interface ReadCaps {
|
|
27
28
|
maxBytes: number;
|
|
28
29
|
maxLines: number;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/** Max Event Log records a single watch_log delta read pulls (bounded follow). */
|
|
33
|
+
const MAX_WIN_WATCH_EVENTS = 1000;
|
|
34
|
+
|
|
31
35
|
/**
|
|
32
36
|
* POSIX single-quote a value for safe interpolation into a remote shell command
|
|
33
37
|
* (ssh2's exec always runs through the login shell). Mirrors mokossh `shq()`:
|
|
@@ -327,6 +331,13 @@ export async function tail(
|
|
|
327
331
|
): Promise<ReadResult> {
|
|
328
332
|
const n = Math.min(opts.lines, opts.caps.maxLines);
|
|
329
333
|
|
|
334
|
+
// Windows Event Log (#14): `target` is a channel name; fetch the N newest events
|
|
335
|
+
// rendered one-per-line. Rotation does not apply (the Event Log has no .1/.gz).
|
|
336
|
+
if (source.type === 'windows') {
|
|
337
|
+
const win = await winFetchEvents(opts.target, n);
|
|
338
|
+
return toResult(sourceName, opts.target, win, { maxBytes: opts.caps.maxBytes, maxLines: n });
|
|
339
|
+
}
|
|
340
|
+
|
|
330
341
|
if (opts.includeRotated) {
|
|
331
342
|
const segs = await resolveRotatedFiles(source, opts.target); // oldest → newest
|
|
332
343
|
let combined = '';
|
|
@@ -358,6 +369,20 @@ export async function grep(
|
|
|
358
369
|
): Promise<ReadResult> {
|
|
359
370
|
const n = Math.min(opts.lines, opts.caps.maxLines);
|
|
360
371
|
|
|
372
|
+
// Windows Event Log (#14): fetch the recent events (bounded by maxLines) rendered
|
|
373
|
+
// one-per-line, then filter by the pattern on the rendered line. This is what lets
|
|
374
|
+
// get_errors / get_server_status match Level words (Error/Critical/Warning).
|
|
375
|
+
if (source.type === 'windows') {
|
|
376
|
+
const raw = await winFetchEvents(opts.target, opts.caps.maxLines);
|
|
377
|
+
const re = new RegExp(opts.pattern, opts.ignoreCase ? 'i' : undefined);
|
|
378
|
+
const matched = raw
|
|
379
|
+
.split(/\r?\n/)
|
|
380
|
+
.filter((l) => l && re.test(l))
|
|
381
|
+
.slice(-n)
|
|
382
|
+
.join('\n');
|
|
383
|
+
return toResult(sourceName, opts.target, matched, opts.caps);
|
|
384
|
+
}
|
|
385
|
+
|
|
361
386
|
if (opts.includeRotated) {
|
|
362
387
|
const segs = await resolveRotatedFiles(source, opts.target); // oldest → newest
|
|
363
388
|
let combined = '';
|
|
@@ -398,6 +423,9 @@ export interface LogFileInfo {
|
|
|
398
423
|
* `extensions` (default `.log`). Returns path/size/mtime, capped to `limit`.
|
|
399
424
|
*/
|
|
400
425
|
export async function listLogs(source: LogSource, caps: ReadCaps, limit = 200): Promise<LogFileInfo[]> {
|
|
426
|
+
// Windows Event Log (#14): the "files" are the channels this source reads.
|
|
427
|
+
if (source.type === 'windows') return winListLogs(source, limit);
|
|
428
|
+
|
|
401
429
|
const exts = source.extensions ?? ['.log'];
|
|
402
430
|
const dirs = source.logDirs ?? (source.ssh ? ['/var/log'] : ['.']);
|
|
403
431
|
const out: LogFileInfo[] = [];
|
|
@@ -439,6 +467,8 @@ export async function listLogs(source: LogSource, caps: ReadCaps, limit = 200):
|
|
|
439
467
|
|
|
440
468
|
/** Byte size of a file (local or remote); 0 when missing. */
|
|
441
469
|
export async function sizeOf(source: LogSource, path: string): Promise<number> {
|
|
470
|
+
// Windows Event Log (#14): the watch cursor is the newest RecordId (monotonic).
|
|
471
|
+
if (source.type === 'windows') return winNewestRecordId(path);
|
|
442
472
|
if (source.ssh) {
|
|
443
473
|
const out = await sshRun(source, `stat -c %s ${shq(path)} 2>/dev/null || wc -c < ${shq(path)} 2>/dev/null`, 64);
|
|
444
474
|
return parseInt(out.trim(), 10) || 0;
|
|
@@ -452,6 +482,9 @@ export async function sizeOf(source: LogSource, path: string): Promise<number> {
|
|
|
452
482
|
|
|
453
483
|
/** Read a file from byte `offset` to EOF, byte-capped (the watch_log delta read). */
|
|
454
484
|
export async function readFrom(source: LogSource, path: string, offset: number, maxBytes: number): Promise<string> {
|
|
485
|
+
// Windows Event Log (#14): `offset` is the last-seen RecordId cursor — return the
|
|
486
|
+
// rendered events newer than it (bounded). maxBytes is applied by the caller's cap.
|
|
487
|
+
if (source.type === 'windows') return winFetchEvents(path, MAX_WIN_WATCH_EVENTS, offset);
|
|
455
488
|
if (source.ssh) {
|
|
456
489
|
return sshRun(source, `tail -c +${offset + 1} ${shq(path)} 2>/dev/null | head -c ${maxBytes}`, maxBytes);
|
|
457
490
|
}
|
package/source/winlog.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
2
|
+
*
|
|
3
|
+
* This file is part of a Moko Consulting project.
|
|
4
|
+
*
|
|
5
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
+
*
|
|
7
|
+
* FILE INFORMATION
|
|
8
|
+
* DEFGROUP: mokologs-mcp.WinLog
|
|
9
|
+
* INGROUP: mokologs-mcp
|
|
10
|
+
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokologs-mcp
|
|
11
|
+
* PATH: /source/winlog.ts
|
|
12
|
+
* VERSION: 01.00.00
|
|
13
|
+
* BRIEF: Windows Event Log source adapter (#14). Reads local Event Log channels
|
|
14
|
+
* (System/Application/Security/…) via PowerShell Get-WinEvent and renders
|
|
15
|
+
* each event as ONE line — `<iso> <Level> <Channel> <Provider>[<Id>]: <msg>`
|
|
16
|
+
* — so the generic reader tools (tail/grep/get_errors/get_server_status)
|
|
17
|
+
* work unchanged against it. Local Windows only in this version.
|
|
18
|
+
*
|
|
19
|
+
* SECURITY: every caller-supplied value (channel, max, cursor) is passed to
|
|
20
|
+
* PowerShell via the child ENVIRONMENT and referenced as a $env: variable in
|
|
21
|
+
* a FIXED script — never string-interpolated into the command — so a channel
|
|
22
|
+
* name cannot inject PowerShell. powershell.exe is resolved to an absolute
|
|
23
|
+
* path. Channel names are also charset-validated as defense in depth.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFile } from 'node:child_process';
|
|
27
|
+
import { promisify } from 'node:util';
|
|
28
|
+
import { statSync } from 'node:fs';
|
|
29
|
+
import { join, resolve as presolve } from 'node:path';
|
|
30
|
+
import type { LogSource } from './types.js';
|
|
31
|
+
import type { LogFileInfo } from './reader.js';
|
|
32
|
+
|
|
33
|
+
const execFileP = promisify(execFile);
|
|
34
|
+
const IS_WIN = process.platform === 'win32';
|
|
35
|
+
|
|
36
|
+
/** Event Log channels used when a windows source lists none (via `logDirs`). */
|
|
37
|
+
const DEFAULT_CHANNELS = ['System', 'Application', 'Security', 'Setup'];
|
|
38
|
+
|
|
39
|
+
/** Valid Event Log channel name (e.g. `System`, `Microsoft-Windows-Kernel-Power/Operational`). */
|
|
40
|
+
const CHANNEL_RE = /^[A-Za-z0-9 _.\-/%()]+$/;
|
|
41
|
+
|
|
42
|
+
const PS_TIMEOUT_MS = 30_000;
|
|
43
|
+
const MAX_EVENTS_CEILING = 5000;
|
|
44
|
+
|
|
45
|
+
function assertWindows(): void {
|
|
46
|
+
if (!IS_WIN) {
|
|
47
|
+
throw new Error('windows source: Event Log access requires the mokologs MCP to run on Windows (local only in this version).');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The Event Log channels this source reads (charset-validated). */
|
|
52
|
+
export function channelsOf(source: LogSource): string[] {
|
|
53
|
+
const list = source.logDirs && source.logDirs.length ? source.logDirs : DEFAULT_CHANNELS;
|
|
54
|
+
return list.filter((c) => CHANNEL_RE.test(c));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Resolve powershell.exe to an ABSOLUTE path (canonical System32 first, then PATH:
|
|
58
|
+
// pwsh.exe / powershell.exe). Never run a bare name — avoids cwd/planted-binary pickup.
|
|
59
|
+
let cachedPwsh: string | null | undefined;
|
|
60
|
+
function resolvePowershell(): string {
|
|
61
|
+
if (cachedPwsh !== undefined) {
|
|
62
|
+
if (cachedPwsh) return cachedPwsh;
|
|
63
|
+
throw new Error('PowerShell not found.');
|
|
64
|
+
}
|
|
65
|
+
const isFile = (p: string): boolean => {
|
|
66
|
+
try { return statSync(p).isFile(); } catch { return false; }
|
|
67
|
+
};
|
|
68
|
+
const sysRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
|
|
69
|
+
const candidates = [join(sysRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')];
|
|
70
|
+
for (const d of (process.env.PATH ?? '').split(';')) {
|
|
71
|
+
if (d && d.trim() && d !== '.') candidates.push(join(d, 'pwsh.exe'), join(d, 'powershell.exe'));
|
|
72
|
+
}
|
|
73
|
+
for (const c of candidates) {
|
|
74
|
+
if (isFile(c)) { cachedPwsh = presolve(c); return cachedPwsh; }
|
|
75
|
+
}
|
|
76
|
+
cachedPwsh = null;
|
|
77
|
+
throw new Error('PowerShell not found (looked for System32 powershell.exe and pwsh/powershell on PATH).');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Run a FIXED PowerShell script. Caller parameters are supplied via a minimal
|
|
82
|
+
* child environment (MLOG_* keys) and read inside the script as `$env:MLOG_*`
|
|
83
|
+
* variables — they are NEVER concatenated into the command string, so no value
|
|
84
|
+
* can inject PowerShell. A non-zero exit returns whatever stdout was produced.
|
|
85
|
+
*/
|
|
86
|
+
async function runPs(script: string, params: Record<string, string>): Promise<string> {
|
|
87
|
+
assertWindows();
|
|
88
|
+
const bin = resolvePowershell();
|
|
89
|
+
const env: NodeJS.ProcessEnv = {
|
|
90
|
+
SystemRoot: process.env.SystemRoot,
|
|
91
|
+
windir: process.env.windir,
|
|
92
|
+
PATH: process.env.PATH,
|
|
93
|
+
Path: process.env.Path,
|
|
94
|
+
PATHEXT: process.env.PATHEXT,
|
|
95
|
+
TEMP: process.env.TEMP,
|
|
96
|
+
TMP: process.env.TMP,
|
|
97
|
+
USERPROFILE: process.env.USERPROFILE,
|
|
98
|
+
...params,
|
|
99
|
+
};
|
|
100
|
+
try {
|
|
101
|
+
const { stdout } = await execFileP(bin, ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
102
|
+
env,
|
|
103
|
+
timeout: PS_TIMEOUT_MS,
|
|
104
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
105
|
+
windowsHide: true,
|
|
106
|
+
shell: false,
|
|
107
|
+
});
|
|
108
|
+
return stdout ?? '';
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return (err as { stdout?: string }).stdout ?? '';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Render N events (optionally only those newer than a RecordId cursor) as one
|
|
115
|
+
// line each, oldest→newest. Fixed script; channel/max/since arrive via $env.
|
|
116
|
+
const RENDER_SCRIPT = `
|
|
117
|
+
$ErrorActionPreference='SilentlyContinue'
|
|
118
|
+
$ch=$env:MLOG_CHANNEL
|
|
119
|
+
$max=[int]$env:MLOG_MAX
|
|
120
|
+
$since=$env:MLOG_SINCE
|
|
121
|
+
try { $ev = Get-WinEvent -LogName $ch -MaxEvents $max -ErrorAction Stop } catch { $ev=@() }
|
|
122
|
+
if ($since) { $sid=[int64]$since; $ev = $ev | Where-Object { $_.RecordId -gt $sid } }
|
|
123
|
+
$ev | Sort-Object RecordId | ForEach-Object {
|
|
124
|
+
$lvl=$_.LevelDisplayName; if(-not $lvl){$lvl='Information'}
|
|
125
|
+
$m=$_.Message; if(-not $m){$m=''}
|
|
126
|
+
$m=($m -replace '\\r?\\n',' ')
|
|
127
|
+
if($m.Length -gt 2000){$m=$m.Substring(0,2000)}
|
|
128
|
+
'{0} {1} {2} {3}[{4}]: {5}' -f $_.TimeCreated.ToString('s'), $lvl, $ch, $_.ProviderName, $_.Id, $m
|
|
129
|
+
}`;
|
|
130
|
+
|
|
131
|
+
/** Fetch rendered event lines for a channel (optionally since a RecordId cursor). */
|
|
132
|
+
export async function winFetchEvents(channel: string, maxEvents: number, sinceRecordId?: number): Promise<string> {
|
|
133
|
+
assertWindows();
|
|
134
|
+
if (!CHANNEL_RE.test(channel)) throw new Error(`windows source: invalid Event Log channel name "${channel}".`);
|
|
135
|
+
const max = Math.max(1, Math.min(Math.floor(maxEvents) || 1, MAX_EVENTS_CEILING));
|
|
136
|
+
const params: Record<string, string> = { MLOG_CHANNEL: channel, MLOG_MAX: String(max) };
|
|
137
|
+
if (sinceRecordId !== undefined && Number.isFinite(sinceRecordId)) params.MLOG_SINCE = String(Math.floor(sinceRecordId));
|
|
138
|
+
return runPs(RENDER_SCRIPT, params);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** List the source's Event Log channels as "files" (name, record count, newest write time). */
|
|
142
|
+
export async function winListLogs(source: LogSource, limit = 200): Promise<LogFileInfo[]> {
|
|
143
|
+
assertWindows();
|
|
144
|
+
const channels = channelsOf(source);
|
|
145
|
+
if (!channels.length) return [];
|
|
146
|
+
const script = `
|
|
147
|
+
$ErrorActionPreference='SilentlyContinue'
|
|
148
|
+
($env:MLOG_CHANNELS -split ';') | ForEach-Object {
|
|
149
|
+
if(-not $_){ return }
|
|
150
|
+
try {
|
|
151
|
+
$l = Get-WinEvent -ListLog $_ -ErrorAction Stop
|
|
152
|
+
$t = if($l.LastWriteTime){ $l.LastWriteTime.ToString('s') } else { '' }
|
|
153
|
+
'{0}\t{1}\t{2}' -f [int64]$l.RecordCount, $t, $l.LogName
|
|
154
|
+
} catch {}
|
|
155
|
+
}`;
|
|
156
|
+
const out = await runPs(script, { MLOG_CHANNELS: channels.join(';') });
|
|
157
|
+
const files: LogFileInfo[] = [];
|
|
158
|
+
for (const line of out.split(/\r?\n/)) {
|
|
159
|
+
if (!line.trim()) continue;
|
|
160
|
+
const [size, mtime, ...rest] = line.split('\t');
|
|
161
|
+
const path = rest.join('\t').trim();
|
|
162
|
+
if (path) files.push({ path, size: Number(size) || 0, mtime: (mtime || '').trim() });
|
|
163
|
+
}
|
|
164
|
+
return files.slice(0, limit);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Newest RecordId in a channel — the monotonic cursor used by watch_log. 0 when empty/absent. */
|
|
168
|
+
export async function winNewestRecordId(channel: string): Promise<number> {
|
|
169
|
+
assertWindows();
|
|
170
|
+
if (!CHANNEL_RE.test(channel)) return 0;
|
|
171
|
+
const out = await runPs(
|
|
172
|
+
`$ErrorActionPreference='SilentlyContinue'
|
|
173
|
+
try { $e = Get-WinEvent -LogName $env:MLOG_CHANNEL -MaxEvents 1 -ErrorAction Stop; if($e){ [int64]$e[0].RecordId } else { 0 } } catch { 0 }`,
|
|
174
|
+
{ MLOG_CHANNEL: channel },
|
|
175
|
+
);
|
|
176
|
+
return parseInt(out.trim(), 10) || 0;
|
|
177
|
+
}
|