@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.
@@ -14,7 +14,7 @@
14
14
  DEFGROUP:
15
15
  INGROUP: Project.Documentation
16
16
  REPO: MokoConsulting/Template-NPM
17
- VERSION: 04.01.00
17
+ VERSION: 04.02.00
18
18
  PATH: ./CODE_OF_CONDUCT.md
19
19
  BRIEF: Contributor Covenant Code of Conduct version 1.3.0
20
20
  -->
package/CONTRIBUTING.md CHANGED
@@ -14,7 +14,7 @@
14
14
  DEFGROUP:
15
15
  INGROUP: Project.Documentation
16
16
  REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-NPM
17
- VERSION: 04.01.00
17
+ VERSION: 04.02.00
18
18
  PATH: ./CONTRIBUTING.md
19
19
  BRIEF: Contribution guidelines for the project
20
20
  -->
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.01.00
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.01.00
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.1.0",
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
  }
@@ -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
+ }