@hasna/logs 0.3.24 → 0.3.26

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.
@@ -6,7 +6,7 @@ import {
6
6
  setPageAuth,
7
7
  setRetentionPolicy,
8
8
  startScheduler
9
- } from "../index-pf8hpweg.js";
9
+ } from "../index-gc0zvs88.js";
10
10
  import {
11
11
  exportToCsv,
12
12
  exportToJson
@@ -37,7 +37,7 @@ import {
37
37
  updateAlertRule,
38
38
  updateIssueStatus,
39
39
  updateProject
40
- } from "../index-g8f8kep6.js";
40
+ } from "../index-pen6t0yc.js";
41
41
  import {
42
42
  createJob,
43
43
  deleteJob,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/logs",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
4
4
  "description": "Log aggregation + browser script + headless page scanner + performance monitoring for AI agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -40,12 +40,13 @@
40
40
  "author": "Andrei Hasna <andrei@hasna.com>",
41
41
  "license": "Apache-2.0",
42
42
  "dependencies": {
43
- "@modelcontextprotocol/sdk": "^1.12.1",
43
+ "@hasna/cloud": "0.1.24",
44
+ "@hasna/events": "^0.1.6",
45
+ "@modelcontextprotocol/sdk": "^1.29.0",
44
46
  "commander": "^14.0.0",
45
47
  "hono": "^4.7.11",
46
48
  "ink": "^5.1.0",
47
49
  "node-cron": "^3.0.3",
48
- "pg": "^8.20.0",
49
50
  "playwright": "^1.52.0",
50
51
  "react": "^19.1.0"
51
52
  },
@@ -53,7 +54,6 @@
53
54
  "@biomejs/biome": "^1.9.4",
54
55
  "@types/bun": "latest",
55
56
  "@types/node-cron": "^3.0.11",
56
- "@types/pg": "^8.20.0",
57
57
  "@types/react": "^19.1.4",
58
58
  "typescript": "^5.9.3"
59
59
  }
package/sdk/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hasna/logs-sdk",
3
3
  "version": "0.1.0",
4
- "description": "Zero-dependency fetch client for @hasna/logs push logs, query, browse issues, perf snapshots",
4
+ "description": "Zero-dependency fetch client for @hasna/logs \u2014 push logs, query, browse issues, perf snapshots",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -10,13 +10,18 @@
10
10
  "./browser": "./dist/index.js"
11
11
  },
12
12
  "publishConfig": {
13
- "access": "restricted",
13
+ "access": "public",
14
14
  "registry": "https://registry.npmjs.org/"
15
15
  },
16
16
  "scripts": {
17
17
  "build": "bun build src/index.ts --outdir dist --target browser"
18
18
  },
19
- "keywords": ["logs", "monitoring", "sdk", "ai-agents"],
19
+ "keywords": [
20
+ "logs",
21
+ "monitoring",
22
+ "sdk",
23
+ "ai-agents"
24
+ ],
20
25
  "author": "Andrei Hasna <andrei@hasna.com>",
21
- "license": "MIT"
26
+ "license": "Apache-2.0"
22
27
  }
@@ -46,7 +46,7 @@ test("logs-mcp --help prints usage and exits without starting stdio transport",
46
46
 
47
47
  expect(result.exitCode).toBe(0)
48
48
  expect(result.stdout).toContain("Usage: logs-mcp [options]")
49
- expect(result.stdout).toContain("Start the @hasna/logs MCP server over stdio.")
49
+ expect(result.stdout).toContain("Start the @hasna/logs MCP server (stdio by default).")
50
50
  expect(result.stdout).not.toContain("Listening")
51
51
  expect(result.stderr.trim()).toBe("")
52
52
  })
package/src/cli/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { registerEventsCommands } from "@hasna/events/commander";
2
3
  import { Command } from "commander"
3
4
  import { getDb } from "../db/index.ts"
4
5
  import { ingestLog } from "../lib/ingest.ts"
@@ -465,5 +466,6 @@ function parseRelativeTime(val?: string): string | undefined {
465
466
  const ms = Number(n) * (unit === "h" ? 3600 : unit === "d" ? 86400 : 60) * 1000
466
467
  return new Date(Date.now() - ms).toISOString()
467
468
  }
469
+ registerEventsCommands(program, { source: "logs" });
468
470
 
469
471
  program.parse()
package/src/db/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Database } from "bun:sqlite"
1
+ import { SqliteAdapter as Database } from "@hasna/cloud"
2
2
  import { join } from "node:path"
3
3
  import { existsSync, mkdirSync, cpSync } from "node:fs"
4
4
  import { migrateAlertRules } from "./migrations/001_alert_rules.ts"
package/src/lib/ingest.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Database as DbAdapter } from "bun:sqlite"
1
+ import type { DbAdapter } from "@hasna/cloud"
2
2
  import type { LogEntry, LogRow } from "../types/index.ts"
3
3
  import { upsertIssue } from "./issues.ts"
4
4
  import { evaluateAlerts } from "./alerts.ts"
@@ -47,7 +47,7 @@ export function ingestBatch(db: DbAdapter, entries: LogEntry[], sharedTraceId?:
47
47
  VALUES ($project_id, $page_id, $level, $source, $service, $message, $trace_id, $session_id, $agent, $url, $stack_trace, $metadata)
48
48
  RETURNING *
49
49
  `)
50
- // Bun returns a callable transaction wrapper; invoke it to execute the batch.
50
+ // @hasna/cloud executes the callback inside the transaction immediately.
51
51
  const rows = db.transaction(() =>
52
52
  entries.map(entry =>
53
53
  insert.get({
@@ -65,7 +65,7 @@ export function ingestBatch(db: DbAdapter, entries: LogEntry[], sharedTraceId?:
65
65
  $metadata: entry.metadata ? JSON.stringify(entry.metadata) : null,
66
66
  }) as LogRow
67
67
  )
68
- )()
68
+ )
69
69
 
70
70
  // Issue grouping for error-level entries (outside transaction for perf)
71
71
  for (const entry of entries) {
@@ -0,0 +1,92 @@
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
+ });
@@ -0,0 +1,135 @@
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
+ }
package/src/mcp/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
+ import { registerCloudTools } from "@hasna/cloud"
4
5
  import { z } from "zod"
5
6
  import { getDb } from "../db/index.ts"
6
7
  import { exitIfMetadataRequest, PACKAGE_VERSION } from "../lib/package-meta.ts"
@@ -19,21 +20,26 @@ import { compare } from "../lib/compare.ts"
19
20
  import { getHealth } from "../lib/health.ts"
20
21
  import { getSessionContext } from "../lib/session-context.ts"
21
22
  import { parseTime } from "../lib/parse-time.ts"
22
- import { cloudPull, cloudPush, cloudSync, getCloudDatabaseUrl, getCloudPg } from "../lib/cloud-sync.ts"
23
23
  import type { LogLevel, LogRow } from "../types/index.ts"
24
24
 
25
25
  exitIfMetadataRequest({
26
26
  name: "logs-mcp",
27
- description: "Start the @hasna/logs MCP server over stdio.",
27
+ description: "Start the @hasna/logs MCP server (stdio by default).",
28
+ options: [
29
+ " --http Serve MCP over Streamable HTTP (127.0.0.1)",
30
+ " --port <number> HTTP port (default: 8820, env: MCP_HTTP_PORT)",
31
+ ],
28
32
  })
29
33
 
30
34
  const db = getDb()
31
- const server = new McpServer({ name: "logs", version: PACKAGE_VERSION })
32
35
 
33
- // --- in-memory agent registry ---
36
+ // --- in-memory agent registry (module-level for shared HTTP process) ---
34
37
  interface _LogsAgent { id: string; name: string; session_id?: string; last_seen_at: string; project_id?: string }
35
38
  const _logsAgents = new Map<string, _LogsAgent>()
36
39
 
40
+ export function buildServer(): McpServer {
41
+ const server = new McpServer({ name: "logs", version: PACKAGE_VERSION })
42
+
37
43
  const BRIEF_FIELDS: (keyof LogRow)[] = ["id", "timestamp", "level", "message", "service"]
38
44
 
39
45
  function applyBrief(rows: LogRow[], brief = true): unknown[] {
@@ -408,33 +414,31 @@ server.tool("list_agents", "List all registered agents.", {}, async () => {
408
414
  return { content: [{ type: "text" as const, text: JSON.stringify([..._logsAgents.values()]) }] }
409
415
  })
410
416
 
411
- server.tool("cloud_status", "Check configured logs PostgreSQL remote.", {}, async () => {
412
- const url = getCloudDatabaseUrl()
413
- if (!url) return { content: [{ type: "text" as const, text: "cloud: not configured" }] }
414
- let cloud: Awaited<ReturnType<typeof getCloudPg>> | null = null
415
- try {
416
- cloud = await getCloudPg()
417
- await cloud.get("SELECT 1 as ok")
418
- const tables = await cloud.all("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename") as Array<{ tablename: string }>
419
- return { content: [{ type: "text" as const, text: JSON.stringify({ connected: true, tables: tables.map(row => row.tablename) }) }] }
420
- } catch (error) {
421
- return { content: [{ type: "text" as const, text: String(error) }], isError: true }
422
- } finally {
423
- if (cloud) await cloud.close().catch(() => {})
424
- }
425
- })
417
+ registerCloudTools(server, "logs")
418
+ return server
419
+ }
426
420
 
427
- server.tool("cloud_push", "Push local logs data to PostgreSQL.", { tables: z.array(z.string()).optional() }, async ({ tables }) => {
428
- return { content: [{ type: "text" as const, text: JSON.stringify(await cloudPush({ tables })) }] }
429
- })
421
+ async function main(): Promise<void> {
422
+ const { isStdioMode, resolveMcpHttpPort, startMcpHttpServer } = await import("./http.ts")
430
423
 
431
- server.tool("cloud_pull", "Pull logs data from PostgreSQL.", { tables: z.array(z.string()).optional() }, async ({ tables }) => {
432
- return { content: [{ type: "text" as const, text: JSON.stringify(await cloudPull({ tables })) }] }
433
- })
424
+ if (isStdioMode()) {
425
+ const server = buildServer()
426
+ const transport = new StdioServerTransport()
427
+ await server.connect(transport)
428
+ return
429
+ }
434
430
 
435
- server.tool("cloud_sync", "Push local logs data, then pull remote rows.", { tables: z.array(z.string()).optional() }, async ({ tables }) => {
436
- return { content: [{ type: "text" as const, text: JSON.stringify(await cloudSync({ tables })) }] }
437
- })
431
+ // Default: shared Streamable HTTP server (one process per MCP, many agents).
432
+ const handle = await startMcpHttpServer(buildServer, {
433
+ port: resolveMcpHttpPort(),
434
+ })
435
+ process.on("SIGINT", () => void handle.close().finally(() => process.exit(0)))
436
+ process.on("SIGTERM", () => void handle.close().finally(() => process.exit(0)))
437
+ }
438
438
 
439
- const transport = new StdioServerTransport()
440
- await server.connect(transport)
439
+ if (import.meta.main) {
440
+ main().catch((err) => {
441
+ console.error(err)
442
+ process.exit(1)
443
+ })
444
+ }