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));