@hasna/logs 0.3.26 → 0.3.27

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 (130) hide show
  1. package/README.md +33 -10
  2. package/dashboard/dist/assets/index-C0wZYq1m.js +53 -0
  3. package/dashboard/dist/assets/index-DGNrK5qb.css +1 -0
  4. package/dashboard/dist/index.html +14 -0
  5. package/dist/cli/index.js +8511 -177
  6. package/dist/count-bmj4r2zb.js +10 -0
  7. package/dist/{diagnose-e0w5rwbc.js → diagnose-3q5cy9ra.js} +2 -2
  8. package/dist/{export-c3eqjste.js → export-cngdb9fh.js} +1 -1
  9. package/dist/{http-zm3ph78w.js → http-r0xc3d2s.js} +79 -8
  10. package/dist/index-931pbyn5.js +141 -0
  11. package/dist/index-b5c72f1p.js +7 -0
  12. package/dist/{index-gc0zvs88.js → index-bnr19y0h.js} +596 -37
  13. package/dist/{index-7w7v7hnr.js → index-by1pdzbr.js} +14 -5
  14. package/dist/{index-3dr7d80h.js → index-e1930v9b.js} +12 -8
  15. package/dist/{index-eh9bkbpa.js → index-e72k53yq.js} +10 -2
  16. package/dist/{index-edn08m6f.js → index-gcd14q2f.js} +9 -6
  17. package/dist/index-hq6kzaah.js +26 -0
  18. package/dist/index-j34f36wy.js +5672 -0
  19. package/dist/index-p4dbdzx4.js +1849 -0
  20. package/dist/{index-5qznfyah.js → index-q27bgpr1.js} +1086 -1646
  21. package/dist/index-t3x838zw.js +2583 -0
  22. package/dist/{index-ww5ggfv3.js → index-zkb3z95a.js} +12 -9
  23. package/dist/index.js +2982 -22
  24. package/dist/{jobs-ypmmc2ma.js → jobs-hsgyhfvm.js} +2 -1
  25. package/dist/mcp/index.js +1473 -4286
  26. package/dist/{query-7jwj05er.js → query-c5a43zx3.js} +3 -2
  27. package/dist/server/index.js +2944 -417
  28. package/dist/storage.js +50 -0
  29. package/package.json +27 -8
  30. package/biome.json +0 -13
  31. package/bun.lock +0 -376
  32. package/dashboard/README.md +0 -73
  33. package/dashboard/bun.lock +0 -526
  34. package/dashboard/eslint.config.js +0 -23
  35. package/dashboard/index.html +0 -13
  36. package/dashboard/package.json +0 -32
  37. package/dashboard/src/App.css +0 -184
  38. package/dashboard/src/App.tsx +0 -49
  39. package/dashboard/src/api.ts +0 -33
  40. package/dashboard/src/assets/hero.png +0 -0
  41. package/dashboard/src/assets/react.svg +0 -1
  42. package/dashboard/src/assets/vite.svg +0 -1
  43. package/dashboard/src/index.css +0 -111
  44. package/dashboard/src/main.tsx +0 -10
  45. package/dashboard/src/pages/Alerts.tsx +0 -69
  46. package/dashboard/src/pages/Issues.tsx +0 -50
  47. package/dashboard/src/pages/Perf.tsx +0 -75
  48. package/dashboard/src/pages/Projects.tsx +0 -67
  49. package/dashboard/src/pages/Summary.tsx +0 -67
  50. package/dashboard/src/pages/Tail.tsx +0 -65
  51. package/dashboard/tsconfig.app.json +0 -28
  52. package/dashboard/tsconfig.json +0 -7
  53. package/dashboard/tsconfig.node.json +0 -26
  54. package/dashboard/vite.config.ts +0 -14
  55. package/dist/count-x3n7qg3c.js +0 -9
  56. package/dist/index-997bkzr2.js +0 -15
  57. package/dist/index-pen6t0yc.js +0 -10794
  58. package/sdk/package.json +0 -27
  59. package/sdk/src/index.ts +0 -143
  60. package/sdk/src/types.ts +0 -56
  61. package/src/cli/entrypoints.test.ts +0 -63
  62. package/src/cli/index.ts +0 -471
  63. package/src/db/index.test.ts +0 -33
  64. package/src/db/index.ts +0 -189
  65. package/src/db/migrations/001_alert_rules.ts +0 -21
  66. package/src/db/migrations/002_issues.ts +0 -21
  67. package/src/db/migrations/003_retention.ts +0 -15
  68. package/src/db/migrations/004_page_auth.ts +0 -13
  69. package/src/db/pg-migrations.ts +0 -167
  70. package/src/index.ts +0 -1
  71. package/src/lib/alerts.test.ts +0 -67
  72. package/src/lib/alerts.ts +0 -117
  73. package/src/lib/browser-script.test.ts +0 -35
  74. package/src/lib/browser-script.ts +0 -31
  75. package/src/lib/compare.test.ts +0 -52
  76. package/src/lib/compare.ts +0 -85
  77. package/src/lib/count.test.ts +0 -44
  78. package/src/lib/count.ts +0 -55
  79. package/src/lib/diagnose.test.ts +0 -55
  80. package/src/lib/diagnose.ts +0 -91
  81. package/src/lib/export.test.ts +0 -66
  82. package/src/lib/export.ts +0 -65
  83. package/src/lib/github.ts +0 -38
  84. package/src/lib/health.test.ts +0 -48
  85. package/src/lib/health.ts +0 -51
  86. package/src/lib/ingest.test.ts +0 -57
  87. package/src/lib/ingest.ts +0 -78
  88. package/src/lib/issues.test.ts +0 -79
  89. package/src/lib/issues.ts +0 -70
  90. package/src/lib/jobs.test.ts +0 -69
  91. package/src/lib/jobs.ts +0 -63
  92. package/src/lib/lighthouse.ts +0 -65
  93. package/src/lib/package-meta.test.ts +0 -43
  94. package/src/lib/package-meta.ts +0 -80
  95. package/src/lib/page-auth.test.ts +0 -54
  96. package/src/lib/page-auth.ts +0 -48
  97. package/src/lib/parse-time.test.ts +0 -37
  98. package/src/lib/parse-time.ts +0 -14
  99. package/src/lib/perf.test.ts +0 -45
  100. package/src/lib/perf.ts +0 -46
  101. package/src/lib/projects.test.ts +0 -73
  102. package/src/lib/projects.ts +0 -69
  103. package/src/lib/query.test.ts +0 -104
  104. package/src/lib/query.ts +0 -84
  105. package/src/lib/retention.test.ts +0 -42
  106. package/src/lib/retention.ts +0 -62
  107. package/src/lib/rotate.test.ts +0 -37
  108. package/src/lib/rotate.ts +0 -27
  109. package/src/lib/scanner.ts +0 -131
  110. package/src/lib/scheduler.ts +0 -63
  111. package/src/lib/session-context.ts +0 -28
  112. package/src/lib/summarize.test.ts +0 -38
  113. package/src/lib/summarize.ts +0 -23
  114. package/src/mcp/http.test.ts +0 -92
  115. package/src/mcp/http.ts +0 -135
  116. package/src/mcp/index.test.ts +0 -27
  117. package/src/mcp/index.ts +0 -444
  118. package/src/server/index.ts +0 -61
  119. package/src/server/routes/alerts.ts +0 -32
  120. package/src/server/routes/issues.ts +0 -43
  121. package/src/server/routes/jobs.ts +0 -32
  122. package/src/server/routes/logs.ts +0 -113
  123. package/src/server/routes/perf.ts +0 -23
  124. package/src/server/routes/projects.ts +0 -67
  125. package/src/server/routes/stream.ts +0 -43
  126. package/src/server/server.test.ts +0 -194
  127. package/src/types/index.ts +0 -119
  128. package/tsconfig.json +0 -22
  129. /package/dashboard/{public → dist}/favicon.svg +0 -0
  130. /package/dashboard/{public → dist}/icons.svg +0 -0
package/src/lib/rotate.ts DELETED
@@ -1,27 +0,0 @@
1
- import type { Database } from "bun:sqlite"
2
-
3
- const DEFAULT_MAX_ROWS = 100_000
4
-
5
- export function rotateLogs(db: Database, maxRows = DEFAULT_MAX_ROWS): number {
6
- const total = (db.prepare("SELECT COUNT(*) as c FROM logs").get() as { c: number }).c
7
- if (total <= maxRows) return 0
8
- const toDelete = total - maxRows
9
- db.prepare(`
10
- DELETE FROM logs WHERE id IN (
11
- SELECT id FROM logs ORDER BY timestamp ASC LIMIT ${toDelete}
12
- )
13
- `).run()
14
- return toDelete
15
- }
16
-
17
- export function rotateByProject(db: Database, projectId: string, maxRows = DEFAULT_MAX_ROWS): number {
18
- const total = (db.prepare("SELECT COUNT(*) as c FROM logs WHERE project_id = $p").get({ $p: projectId }) as { c: number }).c
19
- if (total <= maxRows) return 0
20
- const toDelete = total - maxRows
21
- db.prepare(`
22
- DELETE FROM logs WHERE id IN (
23
- SELECT id FROM logs WHERE project_id = $p ORDER BY timestamp ASC LIMIT ${toDelete}
24
- )
25
- `).run({ $p: projectId })
26
- return toDelete
27
- }
@@ -1,131 +0,0 @@
1
- import type { Database } from "bun:sqlite"
2
- import { ingestBatch } from "./ingest.ts"
3
- import { getPageAuth } from "./page-auth.ts"
4
- import { saveSnapshot } from "./perf.ts"
5
- import { getPage, touchPage } from "./projects.ts"
6
- import type { LogEntry } from "../types/index.ts"
7
-
8
- export interface ScanResult {
9
- logsCollected: number
10
- errorsFound: number
11
- perfScore: number | null
12
- }
13
-
14
- export async function scanPage(db: Database, projectId: string, pageId: string, urlOverride?: string): Promise<ScanResult> {
15
- const page = getPage(db, pageId)
16
- const url = urlOverride || page?.url
17
- if (!url) throw new Error(`No URL for page ${pageId}`)
18
-
19
- const { chromium } = await import("playwright")
20
- const browser = await chromium.launch({ headless: true })
21
-
22
- // Apply page auth if configured
23
- const auth = getPageAuth(db, pageId)
24
- const contextOptions: Parameters<typeof browser.newContext>[0] = {
25
- userAgent: "Mozilla/5.0 (@hasna/logs scanner) AppleWebKit/537.36",
26
- }
27
- if (auth?.type === "cookie") {
28
- try { contextOptions.storageState = JSON.parse(auth.credentials) } catch { /* invalid */ }
29
- } else if (auth?.type === "basic") {
30
- const [username, password] = auth.credentials.split(":")
31
- contextOptions.httpCredentials = { username: username ?? "", password: password ?? "" }
32
- }
33
-
34
- const context = await browser.newContext(contextOptions)
35
-
36
- if (auth?.type === "bearer") {
37
- await context.route("**/*", (route) => {
38
- route.continue({ headers: { ...route.request().headers(), Authorization: `Bearer ${auth.credentials}` } })
39
- })
40
- }
41
-
42
- const browserPage = await context.newPage()
43
-
44
- const collected: LogEntry[] = []
45
- let errorsFound = 0
46
-
47
- // Capture console output
48
- browserPage.on("console", (msg) => {
49
- const level = msg.type() === "error" ? "error" : msg.type() === "warning" ? "warn" : msg.type() === "info" ? "info" : "debug"
50
- if (level === "error") errorsFound++
51
- collected.push({
52
- project_id: projectId,
53
- page_id: pageId,
54
- level: level as LogEntry["level"],
55
- source: "scanner",
56
- message: msg.text(),
57
- url,
58
- })
59
- })
60
-
61
- // Capture page errors (uncaught JS exceptions)
62
- browserPage.on("pageerror", (err) => {
63
- errorsFound++
64
- collected.push({
65
- project_id: projectId,
66
- page_id: pageId,
67
- level: "error",
68
- source: "scanner",
69
- message: err.message,
70
- stack_trace: err.stack,
71
- url,
72
- })
73
- })
74
-
75
- // Capture network failures
76
- browserPage.on("requestfailed", (req) => {
77
- collected.push({
78
- project_id: projectId,
79
- page_id: pageId,
80
- level: "warn",
81
- source: "scanner",
82
- message: `Network request failed: ${req.url()} — ${req.failure()?.errorText ?? "unknown"}`,
83
- url,
84
- })
85
- })
86
-
87
- let perfScore: number | null = null
88
-
89
- try {
90
- await browserPage.goto(url, { waitUntil: "networkidle", timeout: 30_000 })
91
-
92
- // Try basic perf metrics via CDP
93
- try {
94
- const metrics = await browserPage.evaluate(() => {
95
- const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined
96
- const paint = performance.getEntriesByName("first-contentful-paint")[0]
97
- return {
98
- ttfb: nav ? nav.responseStart - nav.requestStart : null,
99
- fcp: paint?.startTime ?? null,
100
- domLoad: nav ? nav.domContentLoadedEventEnd - nav.startTime : null,
101
- }
102
- })
103
- // Store what we can without full Lighthouse
104
- if (metrics.fcp !== null || metrics.ttfb !== null) {
105
- saveSnapshot(db, {
106
- project_id: projectId,
107
- page_id: pageId,
108
- url,
109
- fcp: metrics.fcp,
110
- ttfb: metrics.ttfb,
111
- lcp: null,
112
- cls: null,
113
- tti: metrics.domLoad,
114
- score: null,
115
- raw_audit: JSON.stringify(metrics),
116
- })
117
- }
118
- } catch {
119
- // perf metrics optional
120
- }
121
- } finally {
122
- await browser.close()
123
- }
124
-
125
- if (collected.length > 0) {
126
- ingestBatch(db, collected)
127
- if (page) touchPage(db, pageId)
128
- }
129
-
130
- return { logsCollected: collected.length, errorsFound, perfScore }
131
- }
@@ -1,63 +0,0 @@
1
- import type { Database } from "bun:sqlite"
2
- import cron from "node-cron"
3
- import { finishScanRun, createScanRun, listJobs, updateJob } from "./jobs.ts"
4
- import { listPages } from "./projects.ts"
5
- import { runRetentionAll } from "./retention.ts"
6
- import { scanPage } from "./scanner.ts"
7
-
8
- const tasks = new Map<string, cron.ScheduledTask>()
9
-
10
- export function startScheduler(db: Database): void {
11
- const jobs = listJobs(db).filter(j => j.enabled)
12
- for (const job of jobs) {
13
- scheduleJob(db, job.id, job.schedule, job.project_id, job.page_id ?? undefined)
14
- }
15
- // Hourly retention runner
16
- cron.schedule("0 * * * *", () => {
17
- const result = runRetentionAll(db)
18
- if (result.deleted > 0) console.log(`Retention: deleted ${result.deleted} logs across ${result.projects} project(s)`)
19
- })
20
- console.log(`Scheduler started: ${tasks.size} job(s) active`)
21
- }
22
-
23
- export function scheduleJob(db: Database, jobId: string, schedule: string, projectId: string, pageId?: string): void {
24
- if (tasks.has(jobId)) tasks.get(jobId)!.stop()
25
- const task = cron.schedule(schedule, async () => {
26
- await runJob(db, jobId, projectId, pageId)
27
- })
28
- tasks.set(jobId, task)
29
- }
30
-
31
- export function unscheduleJob(jobId: string): void {
32
- tasks.get(jobId)?.stop()
33
- tasks.delete(jobId)
34
- }
35
-
36
- export async function runJob(db: Database, jobId: string, projectId: string, pageId?: string): Promise<void> {
37
- const pages = pageId
38
- ? [{ id: pageId, url: "" }] // will resolve url in scan
39
- : listPages(db, projectId)
40
-
41
- await Promise.all(pages.map(async (page) => {
42
- const run = createScanRun(db, { job_id: jobId, page_id: page.id })
43
- try {
44
- const result = await scanPage(db, projectId, page.id, page.url)
45
- finishScanRun(db, run.id, {
46
- status: "completed",
47
- logs_collected: result.logsCollected,
48
- errors_found: result.errorsFound,
49
- perf_score: result.perfScore ?? undefined,
50
- })
51
- } catch (err) {
52
- finishScanRun(db, run.id, { status: "failed", logs_collected: 0, errors_found: 0 })
53
- console.error(`Scan failed for page ${page.id}:`, err)
54
- }
55
- }))
56
-
57
- updateJob(db, jobId, { last_run_at: new Date().toISOString() })
58
- }
59
-
60
- export function stopScheduler(): void {
61
- for (const task of tasks.values()) task.stop()
62
- tasks.clear()
63
- }
@@ -1,28 +0,0 @@
1
- import type { Database } from "bun:sqlite"
2
- import type { LogRow } from "../types/index.ts"
3
-
4
- export interface SessionContext {
5
- session_id: string
6
- logs: LogRow[]
7
- session?: Record<string, unknown>
8
- error?: string
9
- }
10
-
11
- export async function getSessionContext(db: Database, sessionId: string): Promise<SessionContext> {
12
- const logs = db.prepare("SELECT * FROM logs WHERE session_id = $s ORDER BY timestamp ASC")
13
- .all({ $s: sessionId }) as LogRow[]
14
-
15
- const sessionsUrl = process.env.SESSIONS_URL
16
- if (!sessionsUrl) {
17
- return { session_id: sessionId, logs }
18
- }
19
-
20
- try {
21
- const res = await fetch(`${sessionsUrl.replace(/\/$/, "")}/api/sessions/${sessionId}`)
22
- if (!res.ok) return { session_id: sessionId, logs }
23
- const session = await res.json() as Record<string, unknown>
24
- return { session_id: sessionId, logs, session }
25
- } catch (err) {
26
- return { session_id: sessionId, logs, error: String(err) }
27
- }
28
- }
@@ -1,38 +0,0 @@
1
- import { describe, expect, it } from "bun:test"
2
- import { createTestDb } from "../db/index.ts"
3
- import { ingestBatch } from "./ingest.ts"
4
- import { summarizeLogs } from "./summarize.ts"
5
-
6
- describe("summarizeLogs", () => {
7
- it("returns warn/error/fatal counts grouped by service", () => {
8
- const db = createTestDb()
9
- ingestBatch(db, [
10
- { level: "error", message: "e1", service: "api" },
11
- { level: "error", message: "e2", service: "api" },
12
- { level: "warn", message: "w1", service: "db" },
13
- { level: "info", message: "i1", service: "api" }, // excluded
14
- { level: "debug", message: "d1", service: "api" }, // excluded
15
- ])
16
- const summary = summarizeLogs(db)
17
- expect(summary.length).toBe(2)
18
- const api = summary.find(s => s.service === "api" && s.level === "error")
19
- expect(api?.count).toBe(2)
20
- const db2 = summary.find(s => s.service === "db")
21
- expect(db2?.count).toBe(1)
22
- })
23
-
24
- it("excludes info/debug from summary", () => {
25
- const db = createTestDb()
26
- ingestBatch(db, [
27
- { level: "info", message: "ok" },
28
- { level: "debug", message: "trace" },
29
- ])
30
- const summary = summarizeLogs(db)
31
- expect(summary).toHaveLength(0)
32
- })
33
-
34
- it("returns empty for no logs", () => {
35
- const db = createTestDb()
36
- expect(summarizeLogs(db)).toHaveLength(0)
37
- })
38
- })
@@ -1,23 +0,0 @@
1
- import type { Database } from "bun:sqlite"
2
- import type { LogSummary } from "../types/index.ts"
3
- import { parseTime } from "./parse-time.ts"
4
-
5
- export function summarizeLogs(db: Database, projectId?: string, since?: string, until?: string): LogSummary[] {
6
- const conditions: string[] = ["level IN ('warn','error','fatal')"]
7
- const params: Record<string, unknown> = {}
8
-
9
- if (projectId) { conditions.push("project_id = $project_id"); params.$project_id = projectId }
10
- if (since) { conditions.push("timestamp >= $since"); params.$since = parseTime(since) ?? since }
11
- if (until) { conditions.push("timestamp <= $until"); params.$until = parseTime(until) ?? until }
12
-
13
- const where = `WHERE ${conditions.join(" AND ")}`
14
- const sql = `
15
- SELECT project_id, service, page_id, level,
16
- COUNT(*) as count,
17
- MAX(timestamp) as latest
18
- FROM logs ${where}
19
- GROUP BY project_id, service, page_id, level
20
- ORDER BY count DESC
21
- `
22
- return db.prepare(sql).all(params) as LogSummary[]
23
- }
@@ -1,92 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
- import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5
- import { buildServer } from "./index.ts";
6
- import {
7
- DEFAULT_MCP_HTTP_PORT,
8
- isHttpMode,
9
- resolveMcpHttpPort,
10
- startMcpHttpServer,
11
- } from "./http.ts";
12
-
13
- describe("logs MCP HTTP transport", () => {
14
- test("defaults port to 8864", () => {
15
- expect(DEFAULT_MCP_HTTP_PORT).toBe(8864);
16
- expect(resolveMcpHttpPort(["node"], {})).toBe(8864);
17
- expect(resolveMcpHttpPort(["node", "--port", "9001"], {})).toBe(9001);
18
- expect(resolveMcpHttpPort(["node"], { MCP_HTTP_PORT: "9002" })).toBe(9002);
19
- });
20
-
21
- test("isHttpMode detects flag and env", () => {
22
- expect(isHttpMode(["node"], {})).toBe(false);
23
- expect(isHttpMode(["node", "--http"], {})).toBe(true);
24
- expect(isHttpMode(["node"], { MCP_HTTP: "1" })).toBe(true);
25
- });
26
- });
27
-
28
- describe("logs buildServer stdio registration", () => {
29
- test("registers tools over in-memory transport", async () => {
30
- const server = buildServer();
31
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
32
- await server.connect(serverTransport);
33
-
34
- const client = new Client({ name: "test", version: "0.0.0" });
35
- await client.connect(clientTransport);
36
-
37
- const tools = await client.listTools();
38
- expect(tools.tools.some((tool) => tool.name === "get_health")).toBe(true);
39
-
40
- await client.close();
41
- await server.close();
42
- });
43
- });
44
-
45
- describe("logs streamable HTTP server", () => {
46
- let handle: Awaited<ReturnType<typeof startMcpHttpServer>>;
47
-
48
- beforeAll(async () => {
49
- handle = await startMcpHttpServer(buildServer, { port: 0 });
50
- });
51
-
52
- afterAll(async () => {
53
- await handle.close();
54
- });
55
-
56
- test("GET /health returns ok", async () => {
57
- const res = await fetch(`http://${handle.host}:${handle.port}/health`);
58
- expect(res.status).toBe(200);
59
- expect(await res.json()).toEqual({ status: "ok", name: "logs" });
60
- });
61
-
62
- test("initialize and call get_health over streamable HTTP", async () => {
63
- const transport = new StreamableHTTPClientTransport(
64
- new URL(`http://${handle.host}:${handle.port}/mcp`),
65
- );
66
- const client = new Client({ name: "test", version: "0.0.0" });
67
- await client.connect(transport);
68
-
69
- const result = await client.callTool({ name: "get_health", arguments: {} });
70
- expect(result.content).toBeDefined();
71
- expect(Array.isArray(result.content)).toBe(true);
72
-
73
- await client.close();
74
- });
75
-
76
- test("serves three concurrent clients from one process", async () => {
77
- const clients = await Promise.all(
78
- Array.from({ length: 3 }, async () => {
79
- const transport = new StreamableHTTPClientTransport(
80
- new URL(`http://${handle.host}:${handle.port}/mcp`),
81
- );
82
- const client = new Client({ name: "test", version: "0.0.0" });
83
- await client.connect(transport);
84
- const tools = await client.listTools();
85
- return { client, count: tools.tools.length };
86
- }),
87
- );
88
-
89
- expect(clients.every((entry) => entry.count > 0)).toBe(true);
90
- await Promise.all(clients.map((entry) => entry.client.close()));
91
- });
92
- });
package/src/mcp/http.ts DELETED
@@ -1,135 +0,0 @@
1
- import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
-
5
- export const MCP_HTTP_SERVICE_NAME = "logs";
6
- export const DEFAULT_MCP_HTTP_PORT = 8864;
7
-
8
- export function isHttpMode(
9
- argv: string[] = process.argv,
10
- env: NodeJS.ProcessEnv = process.env,
11
- ): boolean {
12
- return argv.includes("--http") || env.MCP_HTTP === "1";
13
- }
14
-
15
- export function isStdioMode(
16
- argv: string[] = process.argv,
17
- env: NodeJS.ProcessEnv = process.env,
18
- ): boolean {
19
- return argv.includes("--stdio") || env.MCP_STDIO === "1";
20
- }
21
-
22
- export function resolveMcpHttpPort(
23
- argv: string[] = process.argv,
24
- env: NodeJS.ProcessEnv = process.env,
25
- ): number {
26
- const portIdx = argv.indexOf("--port");
27
- if (portIdx !== -1 && argv[portIdx + 1]) {
28
- return parsePort(argv[portIdx + 1]!, "--port");
29
- }
30
- if (env.MCP_HTTP_PORT) {
31
- return parsePort(env.MCP_HTTP_PORT, "MCP_HTTP_PORT");
32
- }
33
- return DEFAULT_MCP_HTTP_PORT;
34
- }
35
-
36
- function parsePort(raw: string, source: string): number {
37
- const parsed = Number(raw);
38
- if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
39
- throw new Error(`Invalid ${source} value "${raw}". Expected 0-65535.`);
40
- }
41
- return parsed;
42
- }
43
-
44
- async function readJsonBody(req: IncomingMessage): Promise<unknown> {
45
- const chunks: Buffer[] = [];
46
- for await (const chunk of req) {
47
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
48
- }
49
- const text = Buffer.concat(chunks).toString("utf8");
50
- if (!text) return undefined;
51
- return JSON.parse(text) as unknown;
52
- }
53
-
54
- export type McpHttpServerHandle = {
55
- port: number;
56
- host: string;
57
- close: () => Promise<void>;
58
- };
59
-
60
- export async function startMcpHttpServer(
61
- buildServer: () => McpServer,
62
- options?: { port?: number; host?: string; serviceName?: string },
63
- ): Promise<McpHttpServerHandle> {
64
- const host = options?.host ?? "127.0.0.1";
65
- const requestedPort = options?.port ?? resolveMcpHttpPort();
66
- const serviceName = options?.serviceName ?? MCP_HTTP_SERVICE_NAME;
67
-
68
- const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
69
- try {
70
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
71
-
72
- if (req.method === "GET" && url.pathname === "/health") {
73
- res.writeHead(200, { "Content-Type": "application/json" });
74
- res.end(JSON.stringify({ status: "ok", name: serviceName }));
75
- return;
76
- }
77
-
78
- if (url.pathname !== "/mcp") {
79
- res.writeHead(404, { "Content-Type": "text/plain" });
80
- res.end("Not Found");
81
- return;
82
- }
83
-
84
- const server = buildServer();
85
- const transport = new StreamableHTTPServerTransport({
86
- sessionIdGenerator: undefined,
87
- });
88
-
89
- await server.connect(transport);
90
-
91
- let parsedBody: unknown;
92
- if (req.method === "POST") {
93
- parsedBody = await readJsonBody(req);
94
- }
95
-
96
- await transport.handleRequest(req, res, parsedBody);
97
-
98
- res.on("close", () => {
99
- void transport.close();
100
- void server.close();
101
- });
102
- } catch (error) {
103
- console.error(`[${serviceName}-mcp] HTTP error:`, error);
104
- if (!res.headersSent) {
105
- res.writeHead(500, { "Content-Type": "application/json" });
106
- res.end(
107
- JSON.stringify({
108
- jsonrpc: "2.0",
109
- error: { code: -32603, message: "Internal server error" },
110
- id: null,
111
- }),
112
- );
113
- }
114
- }
115
- });
116
-
117
- await new Promise<void>((resolve, reject) => {
118
- httpServer.once("error", reject);
119
- httpServer.listen(requestedPort, host, () => resolve());
120
- });
121
-
122
- const addr = httpServer.address();
123
- const port = typeof addr === "object" && addr ? addr.port : requestedPort;
124
-
125
- console.error(`[${serviceName}-mcp] Streamable HTTP listening on http://${host}:${port}/mcp`);
126
-
127
- return {
128
- port,
129
- host,
130
- close: () =>
131
- new Promise<void>((resolve, reject) => {
132
- httpServer.close((err) => (err ? reject(err) : resolve()));
133
- }),
134
- };
135
- }
@@ -1,27 +0,0 @@
1
- import { test, expect } from "bun:test"
2
- import { fileURLToPath } from "url"
3
- import { Client } from "@modelcontextprotocol/sdk/client/index.js"
4
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
5
-
6
- test("logs MCP lists tools over stdio", async () => {
7
- const entry = fileURLToPath(new URL("./index.ts", import.meta.url))
8
- const transport = new StdioClientTransport({
9
- command: "bun",
10
- args: ["run", entry],
11
- })
12
- const client = new Client({ name: "logs-mcp-test", version: "0.0.0" }, { capabilities: {} })
13
-
14
- try {
15
- await client.connect(transport)
16
- const result = await client.listTools()
17
- const toolNames = result.tools.map((tool) => tool.name)
18
-
19
- expect(toolNames.length).toBeGreaterThan(0)
20
- expect(toolNames).toContain("get_health")
21
- expect(toolNames).toContain("log_export")
22
- expect(toolNames).toContain("log_search")
23
- expect(toolNames).toContain("log_stats")
24
- } finally {
25
- await client.close().catch(() => {})
26
- }
27
- })