From 8b151ae4c4b7011d83e1f0516fbf98d6023ac4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Murat=20=C3=96ZDEM=C4=B0R?= Date: Wed, 25 Mar 2026 15:22:59 +0300 Subject: [PATCH] Adds comprehensive HTTP logging and token persistence Introduces robust HTTP request and tool invocation logging with sensitive data sanitization and configurable file rotation. This improves debuggability and operational oversight. Implements token state persistence to disk, allowing the server to maintain authenticated sessions across restarts. Adds `_with_webhook` variants for alarm registration tools, enabling explicit webhook configuration. Corrects spelling inconsistencies in geographical alarm tools (e.g., 'neighbourhood' to 'neighborhood'). Includes a new build and packaging script. --- README.md | 22 +++- pack.sh | 7 + src/auth.ts | 120 ++++++++++++----- src/client.ts | 199 ++++++++++++++++++++++++---- src/config.ts | 37 ++++++ src/http-logger.ts | 246 +++++++++++++++++++++++++++++++++++ src/index.ts | 29 +++++ src/token-store.ts | 47 +++++++ src/tools/forecast-alarms.ts | 94 ++++++++++++- src/tools/geo-alarms.ts | 107 +++++++++++++-- src/tools/point-alarms.ts | 117 ++++++++++++++++- 11 files changed, 947 insertions(+), 78 deletions(-) create mode 100755 pack.sh create mode 100644 src/http-logger.ts create mode 100644 src/token-store.ts diff --git a/README.md b/README.md index cf4c1fd..e4b3a4a 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,12 @@ IKLIM_BASE_URL= # Opsiyonel. Tanımlıysa IKLIM_ENV'i override IKLIM_HMAC_SECRET= # Zorunlu. İstek imzalama için HMAC-SHA256 anahtarı IKLIM_USERNAME= # Zorunlu. API kullanıcı e-postası IKLIM_PASSWORD= # Zorunlu. API kullanıcı şifresi +IKLIM_TOKEN_STORE_PATH=/home/murat/iklim-mcp-server/token-state.bin # Opsiyonel. Access/refresh token binary dosyası +IKLIM_HTTP_LOG_PATH= # Opsiyonel. API istek log dosyası (örn: /var/log/iklim-mcp/http.log) +IKLIM_HTTP_LOG_MAX_BYTES=5242880 # Opsiyonel. Rotate eşiği (byte), default: 5MB +IKLIM_HTTP_LOG_MAX_FILES=5 # Opsiyonel. Tutulacak rotated dosya sayısı (0 = geçmiş dosya tutma) +IKLIM_HTTP_LOG_REQUEST_BODY_MAX_BYTES=16384 # Opsiyonel. Request body log limiti (byte) +IKLIM_HTTP_LOG_RESPONSE_BODY_MAX_BYTES=16384 # Opsiyonel. Response body log limiti (byte) ``` **🌍 Ortama göre base URL:** @@ -93,6 +99,18 @@ IKLIM_PASSWORD= # Zorunlu. API kullanıcı şifresi | `test` | `https://api-test.iklim.co` | | `local` | `http://localhost:8080` | +**📝 API istek logları (rotate):** + +- `IKLIM_HTTP_LOG_PATH` tanımlıysa MCP server yaptığı tüm API çağrıları için tek satır JSON log yazar. +- Her satırda istek header'ları da (`requestHeaders`) bulunur; hassas alanlar maskelenir (`Authorization`, `X-Signature`, `X-Idempotency-Key`, `X-Nonce`). +- Request body (`requestBody`) loglanır; JSON ise hassas alanlar maskelenir (örn. `password`, `token`) ve değer `IKLIM_HTTP_LOG_REQUEST_BODY_MAX_BYTES` sınırında kırpılır. +- Response header'ları (`responseHeaders`) loglanır; hassas alanlar maskelenir (`Set-Cookie`/`Cookie` dahil). +- Response body (`responseBody`) loglanır; JSON ise hassas alanlar maskelenir (`password`, `secret`, `token` vb.) ve değer `IKLIM_HTTP_LOG_RESPONSE_BODY_MAX_BYTES` sınırında kırpılır. +- Log dosyası `IKLIM_HTTP_LOG_MAX_BYTES` değerini aşınca rotate olur: + - `http.log` → `http.log.1` + - eski `http.log.1` → `http.log.2` ... `http.log.` +- `IKLIM_HTTP_LOG_MAX_FILES` kadar geçmiş dosya tutulur. + --- ## ▶️ Build ve Çalıştırma @@ -452,8 +470,8 @@ Nokta alarmlarıyla aynı imza. | `geo_alarm_get_city` | `cityId` ile il detayı | | `geo_alarm_list_districts` | `cityId` ile ilçeleri listeler | | `geo_alarm_get_district` | `districtId` ile ilçe detayı | -| `geo_alarm_list_neighbourhoods` | `districtId` ile mahalleleri listeler | -| `geo_alarm_get_neighbourhood` | `neighbourhoodId` ile mahalle detayı | +| `geo_alarm_list_neighborhoods` | `districtId` ile mahalleleri listeler | +| `geo_alarm_get_neighborhood` | `neighborhoodId` ile mahalle detayı | --- diff --git a/pack.sh b/pack.sh new file mode 100755 index 0000000..66213cf --- /dev/null +++ b/pack.sh @@ -0,0 +1,7 @@ +npm ci +npm run build +rg -n "initializeHttpLogger" dist/index.js +rg -n "http logger initialized|kind\":\"tool\"" dist/http-logger.js +rm -f iklim-mcp-server-dist.zip +zip -r iklim-mcp-server-dist.zip dist package.json package-lock.json +sha256sum iklim-mcp-server-dist.zip diff --git a/src/auth.ts b/src/auth.ts index f5edf0f..60b1b99 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -5,15 +5,21 @@ import { generateNonce, generateTimestamp, } from "./security.js"; - -interface TokenState { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: number; - refreshTokenExpiresAt: number; -} +import { + headersToRecord, + logHttpRequest, + sanitizeHeaders, + sanitizeRequestBody, + sanitizeResponseBody, +} from "./http-logger.js"; +import { + loadTokenStateFromDisk, + saveTokenStateToDisk, + type TokenState, +} from "./token-store.js"; let tokenState: TokenState | null = null; +let hasLoadedTokenState = false; function parseJwtExpiry(token: string): number { const payload = token.split(".")[1]; @@ -33,30 +39,70 @@ async function callAuthEndpoint( const idempotencyKey = generateIdempotencyKey(); const signature = buildSignature(method, path, timestamp, bodyStr, config.hmacSecret); - const response = await fetch(`${config.baseUrl}${path}`, { - method, - headers: { - "Content-Type": "application/json", - "X-Signature": signature, - "X-Timestamp": timestamp, - "X-Nonce": nonce, - "X-Idempotency-Key": idempotencyKey, - }, - body: bodyStr, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error( - `Auth failed [${response.status}]: ${(error as { message?: string }).message ?? response.statusText}` - ); - } - - const data = (await response.json()) as { - accessToken: string; - refreshToken: string; + const startedAt = Date.now(); + let response: Response | undefined; + let responseHeaders: Record | undefined; + let responseBody: string | undefined; + const requestHeaders: Record = { + "Content-Type": "application/json", + "X-Signature": signature, + "X-Timestamp": timestamp, + "X-Nonce": nonce, + "X-Idempotency-Key": idempotencyKey, }; - return { accessToken: data.accessToken, refreshToken: data.refreshToken }; + + try { + response = await fetch(`${config.baseUrl}${path}`, { + method, + headers: requestHeaders, + body: bodyStr, + }); + const clone = response.clone(); + responseHeaders = sanitizeHeaders(headersToRecord(clone.headers)); + responseBody = sanitizeResponseBody(await clone.text()); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error( + `Auth failed [${response.status}]: ${(error as { message?: string }).message ?? response.statusText}` + ); + } + + const data = (await response.json()) as { + accessToken: string; + refreshToken: string; + }; + + logHttpRequest({ + at: new Date().toISOString(), + method, + path, + status: response.status, + ok: true, + durationMs: Date.now() - startedAt, + requestHeaders: sanitizeHeaders(requestHeaders), + requestBody: sanitizeRequestBody(bodyStr, path), + responseHeaders, + responseBody, + }); + + return { accessToken: data.accessToken, refreshToken: data.refreshToken }; + } catch (error) { + logHttpRequest({ + at: new Date().toISOString(), + method, + path, + status: response?.status ?? null, + ok: false, + durationMs: Date.now() - startedAt, + requestHeaders: sanitizeHeaders(requestHeaders), + requestBody: sanitizeRequestBody(bodyStr, path), + responseHeaders, + responseBody, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } } async function login(): Promise { @@ -71,6 +117,9 @@ async function login(): Promise { accessTokenExpiresAt: parseJwtExpiry(accessToken), refreshTokenExpiresAt: parseJwtExpiry(refreshToken), }; + await saveTokenStateToDisk(tokenState).catch((error) => { + process.stderr.write(`Token store write error: ${String(error)}\n`); + }); } async function refresh(): Promise { @@ -86,12 +135,23 @@ async function refresh(): Promise { accessTokenExpiresAt: parseJwtExpiry(accessToken), refreshTokenExpiresAt: parseJwtExpiry(refreshToken), }; + await saveTokenStateToDisk(tokenState).catch((error) => { + process.stderr.write(`Token store write error: ${String(error)}\n`); + }); +} + +async function ensureTokenStateLoaded(): Promise { + if (hasLoadedTokenState) return; + hasLoadedTokenState = true; + tokenState = await loadTokenStateFromDisk(); } // 30 second buffer to avoid using a token that expires mid-request const EXPIRY_BUFFER_MS = 30_000; export async function getValidAccessToken(): Promise { + await ensureTokenStateLoaded(); + const now = Date.now(); if (tokenState && tokenState.accessTokenExpiresAt - EXPIRY_BUFFER_MS > now) { diff --git a/src/client.ts b/src/client.ts index ab68f38..96cd427 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6,6 +6,13 @@ import { generateNonce, generateTimestamp, } from "./security.js"; +import { + headersToRecord, + logHttpRequest, + sanitizeHeaders, + sanitizeRequestBody, + sanitizeResponseBody, +} from "./http-logger.js"; function buildPathWithQuery(path: string, params?: Record): string { if (!params || Object.keys(params).length === 0) return path; @@ -66,13 +73,47 @@ export async function apiGet( ): Promise { const pathWithQuery = buildPathWithQuery(path, params); const headers = await buildHeaders("GET", pathWithQuery, "", false); + const startedAt = Date.now(); + let response: Response | undefined; + let responseHeaders: Record | undefined; + let responseBody: string | undefined; - const response = await fetch(`${config.baseUrl}${pathWithQuery}`, { - method: "GET", - headers, - }); - - return handleResponse(response); + try { + response = await fetch(`${config.baseUrl}${pathWithQuery}`, { + method: "GET", + headers, + }); + const clone = response.clone(); + responseHeaders = sanitizeHeaders(headersToRecord(clone.headers)); + responseBody = sanitizeResponseBody(await clone.text()); + const result = await handleResponse(response); + logHttpRequest({ + at: new Date().toISOString(), + method: "GET", + path: pathWithQuery, + status: response.status, + ok: true, + durationMs: Date.now() - startedAt, + requestHeaders: sanitizeHeaders(headers), + responseHeaders, + responseBody, + }); + return result; + } catch (error) { + logHttpRequest({ + at: new Date().toISOString(), + method: "GET", + path: pathWithQuery, + status: response?.status ?? null, + ok: false, + durationMs: Date.now() - startedAt, + requestHeaders: sanitizeHeaders(headers), + responseHeaders, + responseBody, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } } export async function apiPost( @@ -81,14 +122,50 @@ export async function apiPost( ): Promise { const bodyStr = JSON.stringify(body); const headers = await buildHeaders("POST", path, bodyStr, true); + const startedAt = Date.now(); + let response: Response | undefined; + let responseHeaders: Record | undefined; + let responseBody: string | undefined; - const response = await fetch(`${config.baseUrl}${path}`, { - method: "POST", - headers, - body: bodyStr, - }); - - return handleResponse(response); + try { + response = await fetch(`${config.baseUrl}${path}`, { + method: "POST", + headers, + body: bodyStr, + }); + const clone = response.clone(); + responseHeaders = sanitizeHeaders(headersToRecord(clone.headers)); + responseBody = sanitizeResponseBody(await clone.text()); + const result = await handleResponse(response); + logHttpRequest({ + at: new Date().toISOString(), + method: "POST", + path, + status: response.status, + ok: true, + durationMs: Date.now() - startedAt, + requestHeaders: sanitizeHeaders(headers), + requestBody: sanitizeRequestBody(bodyStr, path), + responseHeaders, + responseBody, + }); + return result; + } catch (error) { + logHttpRequest({ + at: new Date().toISOString(), + method: "POST", + path, + status: response?.status ?? null, + ok: false, + durationMs: Date.now() - startedAt, + requestHeaders: sanitizeHeaders(headers), + requestBody: sanitizeRequestBody(bodyStr, path), + responseHeaders, + responseBody, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } } export async function apiPatch( @@ -97,23 +174,93 @@ export async function apiPatch( ): Promise { const bodyStr = JSON.stringify(body); const headers = await buildHeaders("PATCH", path, bodyStr, true); + const startedAt = Date.now(); + let response: Response | undefined; + let responseHeaders: Record | undefined; + let responseBody: string | undefined; - const response = await fetch(`${config.baseUrl}${path}`, { - method: "PATCH", - headers, - body: bodyStr, - }); - - return handleResponse(response); + try { + response = await fetch(`${config.baseUrl}${path}`, { + method: "PATCH", + headers, + body: bodyStr, + }); + const clone = response.clone(); + responseHeaders = sanitizeHeaders(headersToRecord(clone.headers)); + responseBody = sanitizeResponseBody(await clone.text()); + const result = await handleResponse(response); + logHttpRequest({ + at: new Date().toISOString(), + method: "PATCH", + path, + status: response.status, + ok: true, + durationMs: Date.now() - startedAt, + requestHeaders: sanitizeHeaders(headers), + requestBody: sanitizeRequestBody(bodyStr, path), + responseHeaders, + responseBody, + }); + return result; + } catch (error) { + logHttpRequest({ + at: new Date().toISOString(), + method: "PATCH", + path, + status: response?.status ?? null, + ok: false, + durationMs: Date.now() - startedAt, + requestHeaders: sanitizeHeaders(headers), + requestBody: sanitizeRequestBody(bodyStr, path), + responseHeaders, + responseBody, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } } export async function apiDelete(path: string): Promise { const headers = await buildHeaders("DELETE", path, "", true); + const startedAt = Date.now(); + let response: Response | undefined; + let responseHeaders: Record | undefined; + let responseBody: string | undefined; - const response = await fetch(`${config.baseUrl}${path}`, { - method: "DELETE", - headers, - }); - - return handleResponse(response); + try { + response = await fetch(`${config.baseUrl}${path}`, { + method: "DELETE", + headers, + }); + const clone = response.clone(); + responseHeaders = sanitizeHeaders(headersToRecord(clone.headers)); + responseBody = sanitizeResponseBody(await clone.text()); + const result = await handleResponse(response); + logHttpRequest({ + at: new Date().toISOString(), + method: "DELETE", + path, + status: response.status, + ok: true, + durationMs: Date.now() - startedAt, + requestHeaders: sanitizeHeaders(headers), + responseHeaders, + responseBody, + }); + return result; + } catch (error) { + logHttpRequest({ + at: new Date().toISOString(), + method: "DELETE", + path, + status: response?.status ?? null, + ok: false, + durationMs: Date.now() - startedAt, + requestHeaders: sanitizeHeaders(headers), + responseHeaders, + responseBody, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } } diff --git a/src/config.ts b/src/config.ts index 5fcb4f8..58063be 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,9 +26,46 @@ function requireEnv(name: string): string { return value; } +function parsePositiveInt(name: string, defaultValue: number): number { + const raw = process.env[name]; + if (!raw) return defaultValue; + + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid ${name} value: "${raw}". Must be a positive integer.`); + } + + return parsed; +} + +function parseNonNegativeInt(name: string, defaultValue: number): number { + const raw = process.env[name]; + if (!raw) return defaultValue; + + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid ${name} value: "${raw}". Must be a non-negative integer.`); + } + + return parsed; +} + export const config = { baseUrl: resolveBaseUrl(), hmacSecret: requireEnv("IKLIM_HMAC_SECRET"), username: requireEnv("IKLIM_USERNAME"), password: requireEnv("IKLIM_PASSWORD"), + tokenStorePath: + process.env.IKLIM_TOKEN_STORE_PATH ?? "/home/murat/iklim-mcp-server/token-state.bin", + httpLogPath: process.env.IKLIM_HTTP_LOG_PATH, + httpLogMaxBytes: parsePositiveInt("IKLIM_HTTP_LOG_MAX_BYTES", 5 * 1024 * 1024), + httpLogMaxFiles: parseNonNegativeInt("IKLIM_HTTP_LOG_MAX_FILES", 5), + httpLogRequestBodyMaxBytes: parseNonNegativeInt( + "IKLIM_HTTP_LOG_REQUEST_BODY_MAX_BYTES", + 16 * 1024 + ), + httpLogResponseBodyMaxBytes: parseNonNegativeInt( + "IKLIM_HTTP_LOG_RESPONSE_BODY_MAX_BYTES", + 16 * 1024 + ), }; diff --git a/src/http-logger.ts b/src/http-logger.ts new file mode 100644 index 0000000..fb3a4ce --- /dev/null +++ b/src/http-logger.ts @@ -0,0 +1,246 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import { config } from "./config.js"; + +type HttpLogEntry = { + kind?: "http"; + at: string; + method: string; + path: string; + status: number | null; + ok: boolean; + durationMs: number; + requestHeaders?: Record; + requestBody?: string; + responseHeaders?: Record; + responseBody?: string; + error?: string; +}; + +type ToolLogEntry = { + kind: "tool"; + at: string; + toolName: string; + ok: boolean; + durationMs: number; + args?: unknown; + error?: string; +}; + +const SENSITIVE_HEADERS = new Set([ + "authorization", + "cookie", + "set-cookie", + "x-signature", + "x-idempotency-key", + "x-nonce", +]); + +const SENSITIVE_BODY_KEY_PARTS = [ + "password", + "secret", + "token", + "authorization", + "signature", + "api_key", + "apikey", +]; + +function maskHeaderValue(value: string): string { + if (!value) return "***"; + if (value.length <= 8) return "***"; + return `${value.slice(0, 4)}...${value.slice(-4)}`; +} + +export function sanitizeHeaders(headers: Record): Record { + const output: Record = {}; + + for (const [key, value] of Object.entries(headers)) { + const lowerKey = key.toLowerCase(); + output[key] = SENSITIVE_HEADERS.has(lowerKey) ? maskHeaderValue(value) : value; + } + + return output; +} + +function isSensitiveBodyKey(key: string): boolean { + const lower = key.toLowerCase(); + return SENSITIVE_BODY_KEY_PARTS.some((part) => lower.includes(part)); +} + +function isSensitiveLoginRequestKey(key: string): boolean { + const lower = key.toLowerCase(); + return lower === "password" || lower.includes("token"); +} + +function sanitizeJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => sanitizeJsonValue(item)); + } + + if (value && typeof value === "object") { + const output: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + output[key] = isSensitiveBodyKey(key) ? "***" : sanitizeJsonValue(nested); + } + return output; + } + + return value; +} + +function truncateByBytes(value: string, maxBytes: number): string { + if (maxBytes <= 0) return "[omitted]"; + + const total = Buffer.byteLength(value, "utf8"); + if (total <= maxBytes) return value; + + const truncated = Buffer.from(value, "utf8").subarray(0, maxBytes).toString("utf8"); + return `${truncated}...[truncated ${total - maxBytes} bytes]`; +} + +export function sanitizeResponseBody(rawBody: string): string { + if (!rawBody) return ""; + + let bodyToLog = rawBody; + try { + const parsed = JSON.parse(rawBody) as unknown; + bodyToLog = JSON.stringify(sanitizeJsonValue(parsed)); + } catch { + bodyToLog = rawBody; + } + + return truncateByBytes(bodyToLog, config.httpLogResponseBodyMaxBytes); +} + +export function sanitizeRequestBody(rawBody: string, path: string): string { + if (!rawBody) return ""; + + let bodyToLog = rawBody; + try { + const parsed = JSON.parse(rawBody) as unknown; + const keyFilter = + path === "/v1/auth/login" ? isSensitiveLoginRequestKey : isSensitiveBodyKey; + bodyToLog = JSON.stringify(sanitizeJsonValueWithKeyFilter(parsed, keyFilter)); + } catch { + bodyToLog = rawBody; + } + + return truncateByBytes(bodyToLog, config.httpLogRequestBodyMaxBytes); +} + +function sanitizeJsonValueWithKeyFilter( + value: unknown, + keyFilter: (key: string) => boolean +): unknown { + if (Array.isArray(value)) { + return value.map((item) => sanitizeJsonValueWithKeyFilter(item, keyFilter)); + } + + if (value && typeof value === "object") { + const output: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + output[key] = keyFilter(key) + ? "***" + : sanitizeJsonValueWithKeyFilter(nested, keyFilter); + } + return output; + } + + return value; +} + +export function headersToRecord(headers: Headers): Record { + const output: Record = {}; + headers.forEach((value, key) => { + output[key] = value; + }); + return output; +} + +let writeQueue: Promise = Promise.resolve(); + +function queueWrite(task: () => Promise): void { + writeQueue = writeQueue.then(task).catch((error) => { + process.stderr.write(`HTTP log write error: ${String(error)}\n`); + }); +} + +async function rotateIfNeeded(logPath: string, incomingBytes: number): Promise { + const maxBytes = config.httpLogMaxBytes; + const maxFiles = config.httpLogMaxFiles; + + const stat = await fs.stat(logPath).catch(() => null); + const currentSize = stat?.size ?? 0; + + if (currentSize + incomingBytes <= maxBytes) return; + + if (maxFiles <= 0) { + await fs.truncate(logPath, 0).catch(() => undefined); + return; + } + + await fs.rm(`${logPath}.${maxFiles}`, { force: true }).catch(() => undefined); + + for (let i = maxFiles - 1; i >= 1; i -= 1) { + const source = `${logPath}.${i}`; + const target = `${logPath}.${i + 1}`; + await fs.rename(source, target).catch(() => undefined); + } + + await fs.rename(logPath, `${logPath}.1`).catch(() => undefined); +} + +export function logHttpRequest(entry: HttpLogEntry): void { + const logPath = config.httpLogPath; + if (!logPath) return; + + const line = `${JSON.stringify({ kind: "http", ...entry })}\n`; + const incomingBytes = Buffer.byteLength(line, "utf8"); + + queueWrite(async () => { + await fs.mkdir(path.dirname(logPath), { recursive: true }); + await rotateIfNeeded(logPath, incomingBytes); + await fs.appendFile(logPath, line, "utf8"); + }); +} + +function sanitizeUnknown(value: unknown): unknown { + return sanitizeJsonValueWithKeyFilter(value, isSensitiveBodyKey); +} + +export function logToolInvocation(entry: ToolLogEntry): void { + const logPath = config.httpLogPath; + if (!logPath) return; + + const payload = { + ...entry, + args: entry.args === undefined ? undefined : sanitizeUnknown(entry.args), + }; + const line = `${JSON.stringify(payload)}\n`; + const incomingBytes = Buffer.byteLength(line, "utf8"); + + queueWrite(async () => { + await fs.mkdir(path.dirname(logPath), { recursive: true }); + await rotateIfNeeded(logPath, incomingBytes); + await fs.appendFile(logPath, line, "utf8"); + }); +} + +export async function initializeHttpLogger(): Promise { + const logPath = config.httpLogPath; + if (!logPath) return; + + await fs.mkdir(path.dirname(logPath), { recursive: true }); + await fs.appendFile( + logPath, + `${JSON.stringify({ + kind: "startup", + at: new Date().toISOString(), + message: "http logger initialized", + path: logPath, + })}\n`, + "utf8" + ); +} diff --git a/src/index.ts b/src/index.ts index 3ba7026..258d42b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { accountTools, handleAccountTool } from "./tools/accounts.js"; import { pointAlarmTools, handlePointAlarmTool } from "./tools/point-alarms.js"; import { geoAlarmTools, handleGeoAlarmTool } from "./tools/geo-alarms.js"; import { forecastAlarmTools, handleForecastAlarmTool } from "./tools/forecast-alarms.js"; +import { initializeHttpLogger, logToolInvocation } from "./http-logger.js"; const toolGroups = [ { tools: lightningTools, handler: handleLightningTool }, @@ -49,8 +50,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const handler = toolHandlerMap.get(name); + const startedAt = Date.now(); if (!handler) { + logToolInvocation({ + kind: "tool", + at: new Date().toISOString(), + toolName: name, + ok: false, + durationMs: Date.now() - startedAt, + args, + error: `Unknown tool: ${name}`, + }); return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, @@ -59,11 +70,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const result = await handler(name, args as Record); + logToolInvocation({ + kind: "tool", + at: new Date().toISOString(), + toolName: name, + ok: true, + durationMs: Date.now() - startedAt, + args, + }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } catch (error) { const message = error instanceof Error ? error.message : String(error); + logToolInvocation({ + kind: "tool", + at: new Date().toISOString(), + toolName: name, + ok: false, + durationMs: Date.now() - startedAt, + args, + error: message, + }); return { content: [{ type: "text", text: `Error: ${message}` }], isError: true, @@ -72,6 +100,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }); async function main(): Promise { + await initializeHttpLogger(); const transport = new StdioServerTransport(); await server.connect(transport); process.stderr.write("iklim.co MCP server running\n"); diff --git a/src/token-store.ts b/src/token-store.ts new file mode 100644 index 0000000..8f50b46 --- /dev/null +++ b/src/token-store.ts @@ -0,0 +1,47 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import { config } from "./config.js"; + +export interface TokenState { + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: number; + refreshTokenExpiresAt: number; +} + +function isValidTokenState(value: unknown): value is TokenState { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + return ( + typeof candidate.accessToken === "string" && + typeof candidate.refreshToken === "string" && + typeof candidate.accessTokenExpiresAt === "number" && + typeof candidate.refreshTokenExpiresAt === "number" + ); +} + +export async function loadTokenStateFromDisk(): Promise { + const filePath = config.tokenStorePath; + try { + const raw = await fs.readFile(filePath); + const parsed = JSON.parse(raw.toString("utf8")) as unknown; + if (!isValidTokenState(parsed)) return null; + return parsed; + } catch { + return null; + } +} + +export async function saveTokenStateToDisk(state: TokenState): Promise { + const filePath = config.tokenStorePath; + const dir = path.dirname(filePath); + const tempPath = `${filePath}.tmp`; + const payload = Buffer.from(JSON.stringify(state), "utf8"); + + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(tempPath, payload, { mode: 0o600 }); + await fs.rename(tempPath, filePath); + await fs.chmod(filePath, 0o600).catch(() => undefined); +} + diff --git a/src/tools/forecast-alarms.ts b/src/tools/forecast-alarms.ts index db71bdd..d5bf851 100644 --- a/src/tools/forecast-alarms.ts +++ b/src/tools/forecast-alarms.ts @@ -58,6 +58,37 @@ export const forecastAlarmTools = [ required: ["recipientId", "boundary"], }, }, + { + name: "forecast_alarm_register_with_webhook", + description: + "Create a new forecast alarm registration with explicit webhook config. Use this when webhook must be provided in the tool call.", + inputSchema: { + type: "object" as const, + properties: { + recipientId: { type: "string", description: "Recipient identifier (US-ASCII)" }, + boundary: { type: "object", description: boundaryDescription }, + webhook: { type: "object", description: webhookDescription }, + forecastDays: { + type: "number", + description: "Number of forecast days ahead to evaluate (1-7)", + }, + forecastAlarmDelivery: { + type: "array", + items: { type: "string", enum: ["MORNING", "EVENING"] }, + description: "Delivery times: MORNING and/or EVENING", + }, + precipitationThreshold: { + type: "string", + enum: ["DRIZZLE", "LIGHT", "MODERATE", "HEAVY", "VERY_HEAVY", "EXTREME"], + }, + snowFallThreshold: { type: "string", enum: ["LIGHT", "MODERATE", "HEAVY"] }, + windGustThreshold: { type: "string", enum: ["STRONG_WIND", "STORM", "SEVERE_STORM", "HURRICANE"] }, + hotTemperatureThreshold: { type: "string", enum: ["HOT_SNAP", "HEAVY_HOT_SNAP", "EXTREME_HOT_SNAP"] }, + coldTemperatureThreshold: { type: "string", enum: ["EXTREME_COLD_SNAP", "HEAVY_COLD_SNAP", "COLD_SNAP"] }, + }, + required: ["recipientId", "boundary", "webhook"], + }, + }, { name: "forecast_alarm_update", description: "Update an existing forecast alarm registration.", @@ -178,6 +209,52 @@ export const forecastAlarmTools = [ }, ] as const; +const DeliveryPolicySchema = z.object({ + timeout: z.number(), + maxRetries: z.number(), + retryDelay: z.number(), +}).passthrough(); + +const WebhookSchema = z.object({ + url: z.string(), + deliveryPolicy: DeliveryPolicySchema, +}).passthrough(); + +const ForecastBoundarySchema = z.object({ + type: z.enum(["ADMINISTRATIVE", "POINT"]), +}).passthrough(); + +const ForecastRegisterSchema = z.object({ + recipientId: z.string(), + boundary: ForecastBoundarySchema, + webhook: WebhookSchema.optional(), + forecastDays: z.number().optional(), + forecastAlarmDelivery: z.array(z.enum(["MORNING", "EVENING"])).optional(), + precipitationThreshold: z.string().optional(), + snowFallThreshold: z.string().optional(), + windGustThreshold: z.string().optional(), + hotTemperatureThreshold: z.string().optional(), + coldTemperatureThreshold: z.string().optional(), +}).passthrough(); +const ForecastRegisterWithWebhookSchema = ForecastRegisterSchema.extend({ + webhook: WebhookSchema, +}); + +function rewriteWebhookRequiredError(error: unknown, toolName: string): never { + const message = error instanceof Error ? error.message : String(error); + const webhookMissing = + message.includes("Webhook and deliveryPolicy is required") || + (message.includes("webhook") && message.includes("deliveryPolicy")); + + if (webhookMissing) { + throw new Error( + `${toolName}: webhook is required for this account. Provide webhook with deliveryPolicy at least once. Example: webhook={ url, httpMethod, contentType, authentication, deliveryPolicy:{ timeout, maxRetries, retryDelay } }` + ); + } + + throw error instanceof Error ? error : new Error(message); +} + function buildForecastAlarmPayload(args: Record): Record { const { precipitationThreshold, @@ -213,8 +290,21 @@ export async function handleForecastAlarmTool( args: Record ): Promise { switch (name) { - case "forecast_alarm_register": - return apiPost("/v1/alarms/forecasts/register", buildForecastAlarmPayload(args)); + case "forecast_alarm_register": { + try { + return await apiPost( + "/v1/alarms/forecasts/register", + buildForecastAlarmPayload(ForecastRegisterSchema.parse(args)) + ); + } catch (error) { + rewriteWebhookRequiredError(error, "forecast_alarm_register"); + } + } + case "forecast_alarm_register_with_webhook": + return apiPost( + "/v1/alarms/forecasts/register", + buildForecastAlarmPayload(ForecastRegisterWithWebhookSchema.parse(args)) + ); case "forecast_alarm_update": return apiPatch("/v1/alarms/forecasts/update", buildForecastAlarmPayload(args)); diff --git a/src/tools/geo-alarms.ts b/src/tools/geo-alarms.ts index 469aef5..c5da926 100644 --- a/src/tools/geo-alarms.ts +++ b/src/tools/geo-alarms.ts @@ -37,6 +37,32 @@ export const geoAlarmTools = [ required: ["recipientId", "boundary"], }, }, + { + name: "geo_alarm_register_with_webhook", + description: + "Create a new geo alarm registration with explicit webhook config. Use this when webhook must be provided in the tool call.", + inputSchema: { + type: "object" as const, + properties: { + recipientId: { type: "string", description: "Recipient identifier (US-ASCII)" }, + boundary: { type: "object", description: boundaryDescription }, + webhook: { type: "object", description: webhookDescription }, + lightningFilter: { + type: "object", + description: "Lightning filter: { type: 'FLASH_CLOUD_TO_GROUND'|'PULSE_IN_CLOUD', peakCurrent: number, inCloudHeight: number }", + }, + thunderstormFilter: { + type: "object", + description: "Thunderstorm filter: { intersectsAffectedPolygon: boolean, intersectsCellPolygon: boolean, severityThreshold: 'LOW'|'MEDIUM'|'HIGH', speedThreshold: number }", + }, + precipitationFilter: { + type: "object", + description: "Precipitation filter: { intensities: ['DRIZZLE'|'LIGHT'|'MODERATE'|'HEAVY'|'VERY_HEAVY'|'EXTREME'] }", + }, + }, + required: ["recipientId", "boundary", "webhook"], + }, + }, { name: "geo_alarm_update", description: "Update an existing geo alarm registration.", @@ -148,8 +174,8 @@ export const geoAlarmTools = [ }, }, { - name: "geo_alarm_list_neighbourhoods", - description: "List all neighbourhoods for a given district.", + name: "geo_alarm_list_neighborhoods", + description: "List all neighborhoods for a given district.", inputSchema: { type: "object" as const, properties: { @@ -159,18 +185,60 @@ export const geoAlarmTools = [ }, }, { - name: "geo_alarm_get_neighbourhood", - description: "Get boundary details for a specific neighbourhood by its ID.", + name: "geo_alarm_get_neighborhood", + description: "Get boundary details for a specific neighborhood by its ID.", inputSchema: { type: "object" as const, properties: { - neighbourhoodId: { type: "number", description: "Neighbourhood ID" }, + neighborhoodId: { type: "number", description: "Neighborhood ID" }, }, - required: ["neighbourhoodId"], + required: ["neighborhoodId"], }, }, ] as const; +const DeliveryPolicySchema = z.object({ + timeout: z.number(), + maxRetries: z.number(), + retryDelay: z.number(), +}).passthrough(); + +const WebhookSchema = z.object({ + url: z.string(), + deliveryPolicy: DeliveryPolicySchema, +}).passthrough(); + +const GeoBoundarySchema = z.object({ + type: z.enum(["ADMINISTRATIVE", "POLYGON", "H3INDEX"]), +}).passthrough(); + +const GeoRegisterSchema = z.object({ + recipientId: z.string(), + boundary: GeoBoundarySchema, + webhook: WebhookSchema.optional(), + lightningFilter: z.record(z.unknown()).optional(), + thunderstormFilter: z.record(z.unknown()).optional(), + precipitationFilter: z.record(z.unknown()).optional(), +}).passthrough(); +const GeoRegisterWithWebhookSchema = GeoRegisterSchema.extend({ + webhook: WebhookSchema, +}); + +function rewriteWebhookRequiredError(error: unknown, toolName: string): never { + const message = error instanceof Error ? error.message : String(error); + const webhookMissing = + message.includes("Webhook and deliveryPolicy is required") || + (message.includes("webhook") && message.includes("deliveryPolicy")); + + if (webhookMissing) { + throw new Error( + `${toolName}: webhook is required for this account. Provide webhook with deliveryPolicy at least once. Example: webhook={ url, httpMethod, contentType, authentication, deliveryPolicy:{ timeout, maxRetries, retryDelay } }` + ); + } + + throw error instanceof Error ? error : new Error(message); +} + function buildGeoRegistrationPayload(args: Record): Record { const { lightningFilter, thunderstormFilter, precipitationFilter, ...rest } = args; @@ -197,8 +265,21 @@ export async function handleGeoAlarmTool( args: Record ): Promise { switch (name) { - case "geo_alarm_register": - return apiPost("/v1/alarms/geometries/register", buildGeoRegistrationPayload(args)); + case "geo_alarm_register": { + try { + return await apiPost( + "/v1/alarms/geometries/register", + buildGeoRegistrationPayload(GeoRegisterSchema.parse(args)) + ); + } catch (error) { + rewriteWebhookRequiredError(error, "geo_alarm_register"); + } + } + case "geo_alarm_register_with_webhook": + return apiPost( + "/v1/alarms/geometries/register", + buildGeoRegistrationPayload(GeoRegisterWithWebhookSchema.parse(args)) + ); case "geo_alarm_update": return apiPatch("/v1/alarms/geometries/update", buildGeoRegistrationPayload(args)); @@ -239,14 +320,14 @@ export async function handleGeoAlarmTool( return apiGet(`/v1/alarms/geometries/district/${districtId}`); } - case "geo_alarm_list_neighbourhoods": { + case "geo_alarm_list_neighborhoods": { const districtId = z.string().parse(args.districtId); - return apiGet(`/v1/alarms/geometries/neighbourhoods/${districtId}`); + return apiGet(`/v1/alarms/geometries/neighborhoods/${districtId}`); } - case "geo_alarm_get_neighbourhood": { - const neighbourhoodId = z.number().parse(args.neighbourhoodId); - return apiGet(`/v1/alarms/geometries/neighbourhood/${neighbourhoodId}`); + case "geo_alarm_get_neighborhood": { + const neighborhoodId = z.number().parse(args.neighborhoodId); + return apiGet(`/v1/alarms/geometries/neighborhood/${neighborhoodId}`); } default: diff --git a/src/tools/point-alarms.ts b/src/tools/point-alarms.ts index 3e47600..5f5c877 100644 --- a/src/tools/point-alarms.ts +++ b/src/tools/point-alarms.ts @@ -3,6 +3,8 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "../client.js"; const webhookDescription = "Webhook callback config. Example: { url: 'https://...', httpMethod: 'POST', contentType: 'application/json', authentication: { type: 'BASIC', username: 'u', password: 'p' }, deliveryPolicy: { timeout: 60, maxRetries: 3, retryDelay: 10 } }"; +const pointBoundaryDescription = + "Point boundary. Required: { type: 'POINT', point: { lat: number, lng: number }, radius: number }"; export const pointAlarmTools = [ { @@ -13,6 +15,7 @@ export const pointAlarmTools = [ type: "object" as const, properties: { recipientId: { type: "string", description: "Recipient identifier (US-ASCII)" }, + boundary: { type: "object", description: pointBoundaryDescription }, latitude: { type: "number", description: "Center point latitude (-90 to 90)" }, longitude: { type: "number", description: "Center point longitude (-180 to 180)" }, radius: { type: "number", description: "Alert radius in meters (0 to 50000)" }, @@ -30,7 +33,33 @@ export const pointAlarmTools = [ description: "Precipitation filter: { intensities: ['DRIZZLE'|'LIGHT'|'MODERATE'|'HEAVY'|'VERY_HEAVY'|'EXTREME'] }", }, }, - required: ["recipientId", "latitude", "longitude", "radius"], + required: ["recipientId", "boundary"], + }, + }, + { + name: "point_alarm_register_with_webhook", + description: + "Create a new point alarm registration with explicit webhook config. Use this when webhook must be provided in the tool call.", + inputSchema: { + type: "object" as const, + properties: { + recipientId: { type: "string", description: "Recipient identifier (US-ASCII)" }, + boundary: { type: "object", description: pointBoundaryDescription }, + webhook: { type: "object", description: webhookDescription }, + lightningFilter: { + type: "object", + description: "Lightning filter: { type: 'FLASH_CLOUD_TO_GROUND'|'PULSE_IN_CLOUD', peakCurrent: number, inCloudHeight: number }", + }, + thunderstormFilter: { + type: "object", + description: "Thunderstorm filter: { intersectsAffectedPolygon: boolean, intersectsCellPolygon: boolean, severityThreshold: 'LOW'|'MEDIUM'|'HIGH', speedThreshold: number }", + }, + precipitationFilter: { + type: "object", + description: "Precipitation filter: { intensities: ['DRIZZLE'|'LIGHT'|'MODERATE'|'HEAVY'|'VERY_HEAVY'|'EXTREME'] }", + }, + }, + required: ["recipientId", "boundary", "webhook"], }, }, { @@ -41,6 +70,7 @@ export const pointAlarmTools = [ properties: { registrationId: { type: "string", description: "Registration UUID to update" }, recipientId: { type: "string", description: "Recipient identifier (optional)" }, + boundary: { type: "object", description: pointBoundaryDescription + " (optional)" }, latitude: { type: "number", description: "New center latitude (optional)" }, longitude: { type: "number", description: "New center longitude (optional)" }, radius: { type: "number", description: "New radius in meters (optional)" }, @@ -105,13 +135,77 @@ export const pointAlarmTools = [ }, ] as const; +const DeliveryPolicySchema = z.object({ + timeout: z.number(), + maxRetries: z.number(), + retryDelay: z.number(), +}).passthrough(); + +const WebhookSchema = z.object({ + url: z.string(), + deliveryPolicy: DeliveryPolicySchema, +}).passthrough(); + +const PointBoundarySchema = z.object({ + type: z.literal("POINT"), + point: z.object({ + lat: z.number(), + lng: z.number(), + }), + radius: z.number(), +}).passthrough(); + +const PointRegisterSchema = z.object({ + recipientId: z.string(), + boundary: PointBoundarySchema, + webhook: WebhookSchema.optional(), + lightningFilter: z.record(z.unknown()).optional(), + thunderstormFilter: z.record(z.unknown()).optional(), + precipitationFilter: z.record(z.unknown()).optional(), +}).passthrough(); +const PointRegisterWithWebhookSchema = PointRegisterSchema.extend({ + webhook: WebhookSchema, +}); + +function rewriteWebhookRequiredError(error: unknown, toolName: string): never { + const message = error instanceof Error ? error.message : String(error); + const webhookMissing = + message.includes("Webhook and deliveryPolicy is required") || + (message.includes("webhook") && message.includes("deliveryPolicy")); + + if (webhookMissing) { + throw new Error( + `${toolName}: webhook is required for this account. Provide webhook with deliveryPolicy at least once. Example: webhook={ url, httpMethod, contentType, authentication, deliveryPolicy:{ timeout, maxRetries, retryDelay } }` + ); + } + + throw error instanceof Error ? error : new Error(message); +} + function buildPointRegistrationPayload(args: Record): Record { - const { latitude, longitude, radius, lightningFilter, thunderstormFilter, precipitationFilter, ...rest } = args; + const { + boundary, + latitude, + longitude, + radius, + lightningFilter, + thunderstormFilter, + precipitationFilter, + ...rest + } = args; const payload: Record = { ...rest }; - if (latitude !== undefined && longitude !== undefined) { + if (boundary && typeof boundary === "object" && !Array.isArray(boundary)) { + const pointBoundary = boundary as Record; payload.boundary = { + type: pointBoundary.type ?? "POINT", + point: pointBoundary.point, + radius: pointBoundary.radius, + }; + } else if (latitude !== undefined && longitude !== undefined) { + payload.boundary = { + type: "POINT", point: { lat: latitude, lng: longitude }, radius: radius ?? 0, }; @@ -138,8 +232,21 @@ export async function handlePointAlarmTool( args: Record ): Promise { switch (name) { - case "point_alarm_register": - return apiPost("/v1/alarms/points/register", buildPointRegistrationPayload(args)); + case "point_alarm_register": { + try { + return await apiPost( + "/v1/alarms/points/register", + buildPointRegistrationPayload(PointRegisterSchema.parse(args)) + ); + } catch (error) { + rewriteWebhookRequiredError(error, "point_alarm_register"); + } + } + case "point_alarm_register_with_webhook": + return apiPost( + "/v1/alarms/points/register", + buildPointRegistrationPayload(PointRegisterWithWebhookSchema.parse(args)) + ); case "point_alarm_update": return apiPatch("/v1/alarms/points/update", buildPointRegistrationPayload(args));