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.
This commit is contained in:
parent
02e8c2a89b
commit
8b151ae4c4
22
README.md
22
README.md
@ -83,6 +83,12 @@ IKLIM_BASE_URL= # Opsiyonel. Tanımlıysa IKLIM_ENV'i override
|
|||||||
IKLIM_HMAC_SECRET=<secret> # Zorunlu. İstek imzalama için HMAC-SHA256 anahtarı
|
IKLIM_HMAC_SECRET=<secret> # Zorunlu. İstek imzalama için HMAC-SHA256 anahtarı
|
||||||
IKLIM_USERNAME=<email> # Zorunlu. API kullanıcı e-postası
|
IKLIM_USERNAME=<email> # Zorunlu. API kullanıcı e-postası
|
||||||
IKLIM_PASSWORD=<password> # Zorunlu. API kullanıcı şifresi
|
IKLIM_PASSWORD=<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:**
|
**🌍 Ortama göre base URL:**
|
||||||
@ -93,6 +99,18 @@ IKLIM_PASSWORD=<password> # Zorunlu. API kullanıcı şifresi
|
|||||||
| `test` | `https://api-test.iklim.co` |
|
| `test` | `https://api-test.iklim.co` |
|
||||||
| `local` | `http://localhost:8080` |
|
| `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.<N>`
|
||||||
|
- `IKLIM_HTTP_LOG_MAX_FILES` kadar geçmiş dosya tutulur.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ▶️ Build ve Çalıştırma
|
## ▶️ Build ve Çalıştırma
|
||||||
@ -452,8 +470,8 @@ Nokta alarmlarıyla aynı imza.
|
|||||||
| `geo_alarm_get_city` | `cityId` ile il detayı |
|
| `geo_alarm_get_city` | `cityId` ile il detayı |
|
||||||
| `geo_alarm_list_districts` | `cityId` ile ilçeleri listeler |
|
| `geo_alarm_list_districts` | `cityId` ile ilçeleri listeler |
|
||||||
| `geo_alarm_get_district` | `districtId` ile ilçe detayı |
|
| `geo_alarm_get_district` | `districtId` ile ilçe detayı |
|
||||||
| `geo_alarm_list_neighbourhoods` | `districtId` ile mahalleleri listeler |
|
| `geo_alarm_list_neighborhoods` | `districtId` ile mahalleleri listeler |
|
||||||
| `geo_alarm_get_neighbourhood` | `neighbourhoodId` ile mahalle detayı |
|
| `geo_alarm_get_neighborhood` | `neighborhoodId` ile mahalle detayı |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
7
pack.sh
Executable file
7
pack.sh
Executable file
@ -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
|
||||||
82
src/auth.ts
82
src/auth.ts
@ -5,15 +5,21 @@ import {
|
|||||||
generateNonce,
|
generateNonce,
|
||||||
generateTimestamp,
|
generateTimestamp,
|
||||||
} from "./security.js";
|
} from "./security.js";
|
||||||
|
import {
|
||||||
interface TokenState {
|
headersToRecord,
|
||||||
accessToken: string;
|
logHttpRequest,
|
||||||
refreshToken: string;
|
sanitizeHeaders,
|
||||||
accessTokenExpiresAt: number;
|
sanitizeRequestBody,
|
||||||
refreshTokenExpiresAt: number;
|
sanitizeResponseBody,
|
||||||
}
|
} from "./http-logger.js";
|
||||||
|
import {
|
||||||
|
loadTokenStateFromDisk,
|
||||||
|
saveTokenStateToDisk,
|
||||||
|
type TokenState,
|
||||||
|
} from "./token-store.js";
|
||||||
|
|
||||||
let tokenState: TokenState | null = null;
|
let tokenState: TokenState | null = null;
|
||||||
|
let hasLoadedTokenState = false;
|
||||||
|
|
||||||
function parseJwtExpiry(token: string): number {
|
function parseJwtExpiry(token: string): number {
|
||||||
const payload = token.split(".")[1];
|
const payload = token.split(".")[1];
|
||||||
@ -33,17 +39,27 @@ async function callAuthEndpoint(
|
|||||||
const idempotencyKey = generateIdempotencyKey();
|
const idempotencyKey = generateIdempotencyKey();
|
||||||
const signature = buildSignature(method, path, timestamp, bodyStr, config.hmacSecret);
|
const signature = buildSignature(method, path, timestamp, bodyStr, config.hmacSecret);
|
||||||
|
|
||||||
const response = await fetch(`${config.baseUrl}${path}`, {
|
const startedAt = Date.now();
|
||||||
method,
|
let response: Response | undefined;
|
||||||
headers: {
|
let responseHeaders: Record<string, string> | undefined;
|
||||||
|
let responseBody: string | undefined;
|
||||||
|
const requestHeaders: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Signature": signature,
|
"X-Signature": signature,
|
||||||
"X-Timestamp": timestamp,
|
"X-Timestamp": timestamp,
|
||||||
"X-Nonce": nonce,
|
"X-Nonce": nonce,
|
||||||
"X-Idempotency-Key": idempotencyKey,
|
"X-Idempotency-Key": idempotencyKey,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(`${config.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: requestHeaders,
|
||||||
body: bodyStr,
|
body: bodyStr,
|
||||||
});
|
});
|
||||||
|
const clone = response.clone();
|
||||||
|
responseHeaders = sanitizeHeaders(headersToRecord(clone.headers));
|
||||||
|
responseBody = sanitizeResponseBody(await clone.text());
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({}));
|
const error = await response.json().catch(() => ({}));
|
||||||
@ -56,7 +72,37 @@ async function callAuthEndpoint(
|
|||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: 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 };
|
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<void> {
|
async function login(): Promise<void> {
|
||||||
@ -71,6 +117,9 @@ async function login(): Promise<void> {
|
|||||||
accessTokenExpiresAt: parseJwtExpiry(accessToken),
|
accessTokenExpiresAt: parseJwtExpiry(accessToken),
|
||||||
refreshTokenExpiresAt: parseJwtExpiry(refreshToken),
|
refreshTokenExpiresAt: parseJwtExpiry(refreshToken),
|
||||||
};
|
};
|
||||||
|
await saveTokenStateToDisk(tokenState).catch((error) => {
|
||||||
|
process.stderr.write(`Token store write error: ${String(error)}\n`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(): Promise<void> {
|
async function refresh(): Promise<void> {
|
||||||
@ -86,12 +135,23 @@ async function refresh(): Promise<void> {
|
|||||||
accessTokenExpiresAt: parseJwtExpiry(accessToken),
|
accessTokenExpiresAt: parseJwtExpiry(accessToken),
|
||||||
refreshTokenExpiresAt: parseJwtExpiry(refreshToken),
|
refreshTokenExpiresAt: parseJwtExpiry(refreshToken),
|
||||||
};
|
};
|
||||||
|
await saveTokenStateToDisk(tokenState).catch((error) => {
|
||||||
|
process.stderr.write(`Token store write error: ${String(error)}\n`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTokenStateLoaded(): Promise<void> {
|
||||||
|
if (hasLoadedTokenState) return;
|
||||||
|
hasLoadedTokenState = true;
|
||||||
|
tokenState = await loadTokenStateFromDisk();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 30 second buffer to avoid using a token that expires mid-request
|
// 30 second buffer to avoid using a token that expires mid-request
|
||||||
const EXPIRY_BUFFER_MS = 30_000;
|
const EXPIRY_BUFFER_MS = 30_000;
|
||||||
|
|
||||||
export async function getValidAccessToken(): Promise<string> {
|
export async function getValidAccessToken(): Promise<string> {
|
||||||
|
await ensureTokenStateLoaded();
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (tokenState && tokenState.accessTokenExpiresAt - EXPIRY_BUFFER_MS > now) {
|
if (tokenState && tokenState.accessTokenExpiresAt - EXPIRY_BUFFER_MS > now) {
|
||||||
|
|||||||
171
src/client.ts
171
src/client.ts
@ -6,6 +6,13 @@ import {
|
|||||||
generateNonce,
|
generateNonce,
|
||||||
generateTimestamp,
|
generateTimestamp,
|
||||||
} from "./security.js";
|
} from "./security.js";
|
||||||
|
import {
|
||||||
|
headersToRecord,
|
||||||
|
logHttpRequest,
|
||||||
|
sanitizeHeaders,
|
||||||
|
sanitizeRequestBody,
|
||||||
|
sanitizeResponseBody,
|
||||||
|
} from "./http-logger.js";
|
||||||
|
|
||||||
function buildPathWithQuery(path: string, params?: Record<string, string>): string {
|
function buildPathWithQuery(path: string, params?: Record<string, string>): string {
|
||||||
if (!params || Object.keys(params).length === 0) return path;
|
if (!params || Object.keys(params).length === 0) return path;
|
||||||
@ -66,13 +73,47 @@ export async function apiGet<T>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const pathWithQuery = buildPathWithQuery(path, params);
|
const pathWithQuery = buildPathWithQuery(path, params);
|
||||||
const headers = await buildHeaders("GET", pathWithQuery, "", false);
|
const headers = await buildHeaders("GET", pathWithQuery, "", false);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let response: Response | undefined;
|
||||||
|
let responseHeaders: Record<string, string> | undefined;
|
||||||
|
let responseBody: string | undefined;
|
||||||
|
|
||||||
const response = await fetch(`${config.baseUrl}${pathWithQuery}`, {
|
try {
|
||||||
|
response = await fetch(`${config.baseUrl}${pathWithQuery}`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
const clone = response.clone();
|
||||||
return handleResponse<T>(response);
|
responseHeaders = sanitizeHeaders(headersToRecord(clone.headers));
|
||||||
|
responseBody = sanitizeResponseBody(await clone.text());
|
||||||
|
const result = await handleResponse<T>(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<T>(
|
export async function apiPost<T>(
|
||||||
@ -81,14 +122,50 @@ export async function apiPost<T>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const bodyStr = JSON.stringify(body);
|
const bodyStr = JSON.stringify(body);
|
||||||
const headers = await buildHeaders("POST", path, bodyStr, true);
|
const headers = await buildHeaders("POST", path, bodyStr, true);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let response: Response | undefined;
|
||||||
|
let responseHeaders: Record<string, string> | undefined;
|
||||||
|
let responseBody: string | undefined;
|
||||||
|
|
||||||
const response = await fetch(`${config.baseUrl}${path}`, {
|
try {
|
||||||
|
response = await fetch(`${config.baseUrl}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: bodyStr,
|
body: bodyStr,
|
||||||
});
|
});
|
||||||
|
const clone = response.clone();
|
||||||
return handleResponse<T>(response);
|
responseHeaders = sanitizeHeaders(headersToRecord(clone.headers));
|
||||||
|
responseBody = sanitizeResponseBody(await clone.text());
|
||||||
|
const result = await handleResponse<T>(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<T>(
|
export async function apiPatch<T>(
|
||||||
@ -97,23 +174,93 @@ export async function apiPatch<T>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const bodyStr = JSON.stringify(body);
|
const bodyStr = JSON.stringify(body);
|
||||||
const headers = await buildHeaders("PATCH", path, bodyStr, true);
|
const headers = await buildHeaders("PATCH", path, bodyStr, true);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let response: Response | undefined;
|
||||||
|
let responseHeaders: Record<string, string> | undefined;
|
||||||
|
let responseBody: string | undefined;
|
||||||
|
|
||||||
const response = await fetch(`${config.baseUrl}${path}`, {
|
try {
|
||||||
|
response = await fetch(`${config.baseUrl}${path}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers,
|
headers,
|
||||||
body: bodyStr,
|
body: bodyStr,
|
||||||
});
|
});
|
||||||
|
const clone = response.clone();
|
||||||
return handleResponse<T>(response);
|
responseHeaders = sanitizeHeaders(headersToRecord(clone.headers));
|
||||||
|
responseBody = sanitizeResponseBody(await clone.text());
|
||||||
|
const result = await handleResponse<T>(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<T>(path: string): Promise<T> {
|
export async function apiDelete<T>(path: string): Promise<T> {
|
||||||
const headers = await buildHeaders("DELETE", path, "", true);
|
const headers = await buildHeaders("DELETE", path, "", true);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let response: Response | undefined;
|
||||||
|
let responseHeaders: Record<string, string> | undefined;
|
||||||
|
let responseBody: string | undefined;
|
||||||
|
|
||||||
const response = await fetch(`${config.baseUrl}${path}`, {
|
try {
|
||||||
|
response = await fetch(`${config.baseUrl}${path}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
const clone = response.clone();
|
||||||
return handleResponse<T>(response);
|
responseHeaders = sanitizeHeaders(headersToRecord(clone.headers));
|
||||||
|
responseBody = sanitizeResponseBody(await clone.text());
|
||||||
|
const result = await handleResponse<T>(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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,9 +26,46 @@ function requireEnv(name: string): string {
|
|||||||
return value;
|
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 = {
|
export const config = {
|
||||||
baseUrl: resolveBaseUrl(),
|
baseUrl: resolveBaseUrl(),
|
||||||
hmacSecret: requireEnv("IKLIM_HMAC_SECRET"),
|
hmacSecret: requireEnv("IKLIM_HMAC_SECRET"),
|
||||||
username: requireEnv("IKLIM_USERNAME"),
|
username: requireEnv("IKLIM_USERNAME"),
|
||||||
password: requireEnv("IKLIM_PASSWORD"),
|
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
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
246
src/http-logger.ts
Normal file
246
src/http-logger.ts
Normal file
@ -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<string, string>;
|
||||||
|
requestBody?: string;
|
||||||
|
responseHeaders?: Record<string, string>;
|
||||||
|
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<string, string>): Record<string, string> {
|
||||||
|
const output: Record<string, string> = {};
|
||||||
|
|
||||||
|
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<string, unknown> = {};
|
||||||
|
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
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<string, unknown> = {};
|
||||||
|
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
output[key] = keyFilter(key)
|
||||||
|
? "***"
|
||||||
|
: sanitizeJsonValueWithKeyFilter(nested, keyFilter);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function headersToRecord(headers: Headers): Record<string, string> {
|
||||||
|
const output: Record<string, string> = {};
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
output[key] = value;
|
||||||
|
});
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
let writeQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
function queueWrite(task: () => Promise<void>): 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/index.ts
29
src/index.ts
@ -14,6 +14,7 @@ import { accountTools, handleAccountTool } from "./tools/accounts.js";
|
|||||||
import { pointAlarmTools, handlePointAlarmTool } from "./tools/point-alarms.js";
|
import { pointAlarmTools, handlePointAlarmTool } from "./tools/point-alarms.js";
|
||||||
import { geoAlarmTools, handleGeoAlarmTool } from "./tools/geo-alarms.js";
|
import { geoAlarmTools, handleGeoAlarmTool } from "./tools/geo-alarms.js";
|
||||||
import { forecastAlarmTools, handleForecastAlarmTool } from "./tools/forecast-alarms.js";
|
import { forecastAlarmTools, handleForecastAlarmTool } from "./tools/forecast-alarms.js";
|
||||||
|
import { initializeHttpLogger, logToolInvocation } from "./http-logger.js";
|
||||||
|
|
||||||
const toolGroups = [
|
const toolGroups = [
|
||||||
{ tools: lightningTools, handler: handleLightningTool },
|
{ tools: lightningTools, handler: handleLightningTool },
|
||||||
@ -49,8 +50,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
const { name, arguments: args } = request.params;
|
const { name, arguments: args } = request.params;
|
||||||
const handler = toolHandlerMap.get(name);
|
const handler = toolHandlerMap.get(name);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
|
logToolInvocation({
|
||||||
|
kind: "tool",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
toolName: name,
|
||||||
|
ok: false,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
args,
|
||||||
|
error: `Unknown tool: ${name}`,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
||||||
isError: true,
|
isError: true,
|
||||||
@ -59,11 +70,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await handler(name, args as Record<string, unknown>);
|
const result = await handler(name, args as Record<string, unknown>);
|
||||||
|
logToolInvocation({
|
||||||
|
kind: "tool",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
toolName: name,
|
||||||
|
ok: true,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
args,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(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 {
|
return {
|
||||||
content: [{ type: "text", text: `Error: ${message}` }],
|
content: [{ type: "text", text: `Error: ${message}` }],
|
||||||
isError: true,
|
isError: true,
|
||||||
@ -72,6 +100,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
await initializeHttpLogger();
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
process.stderr.write("iklim.co MCP server running\n");
|
process.stderr.write("iklim.co MCP server running\n");
|
||||||
|
|||||||
47
src/token-store.ts
Normal file
47
src/token-store.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof candidate.accessToken === "string" &&
|
||||||
|
typeof candidate.refreshToken === "string" &&
|
||||||
|
typeof candidate.accessTokenExpiresAt === "number" &&
|
||||||
|
typeof candidate.refreshTokenExpiresAt === "number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTokenStateFromDisk(): Promise<TokenState | null> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
@ -58,6 +58,37 @@ export const forecastAlarmTools = [
|
|||||||
required: ["recipientId", "boundary"],
|
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",
|
name: "forecast_alarm_update",
|
||||||
description: "Update an existing forecast alarm registration.",
|
description: "Update an existing forecast alarm registration.",
|
||||||
@ -178,6 +209,52 @@ export const forecastAlarmTools = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] 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<string, unknown>): Record<string, unknown> {
|
function buildForecastAlarmPayload(args: Record<string, unknown>): Record<string, unknown> {
|
||||||
const {
|
const {
|
||||||
precipitationThreshold,
|
precipitationThreshold,
|
||||||
@ -213,8 +290,21 @@ export async function handleForecastAlarmTool(
|
|||||||
args: Record<string, unknown>
|
args: Record<string, unknown>
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "forecast_alarm_register":
|
case "forecast_alarm_register": {
|
||||||
return apiPost("/v1/alarms/forecasts/register", buildForecastAlarmPayload(args));
|
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":
|
case "forecast_alarm_update":
|
||||||
return apiPatch("/v1/alarms/forecasts/update", buildForecastAlarmPayload(args));
|
return apiPatch("/v1/alarms/forecasts/update", buildForecastAlarmPayload(args));
|
||||||
|
|||||||
@ -37,6 +37,32 @@ export const geoAlarmTools = [
|
|||||||
required: ["recipientId", "boundary"],
|
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",
|
name: "geo_alarm_update",
|
||||||
description: "Update an existing geo alarm registration.",
|
description: "Update an existing geo alarm registration.",
|
||||||
@ -148,8 +174,8 @@ export const geoAlarmTools = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "geo_alarm_list_neighbourhoods",
|
name: "geo_alarm_list_neighborhoods",
|
||||||
description: "List all neighbourhoods for a given district.",
|
description: "List all neighborhoods for a given district.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
type: "object" as const,
|
||||||
properties: {
|
properties: {
|
||||||
@ -159,18 +185,60 @@ export const geoAlarmTools = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "geo_alarm_get_neighbourhood",
|
name: "geo_alarm_get_neighborhood",
|
||||||
description: "Get boundary details for a specific neighbourhood by its ID.",
|
description: "Get boundary details for a specific neighborhood by its ID.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object" as const,
|
type: "object" as const,
|
||||||
properties: {
|
properties: {
|
||||||
neighbourhoodId: { type: "number", description: "Neighbourhood ID" },
|
neighborhoodId: { type: "number", description: "Neighborhood ID" },
|
||||||
},
|
},
|
||||||
required: ["neighbourhoodId"],
|
required: ["neighborhoodId"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
] as const;
|
] 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<string, unknown>): Record<string, unknown> {
|
function buildGeoRegistrationPayload(args: Record<string, unknown>): Record<string, unknown> {
|
||||||
const { lightningFilter, thunderstormFilter, precipitationFilter, ...rest } = args;
|
const { lightningFilter, thunderstormFilter, precipitationFilter, ...rest } = args;
|
||||||
|
|
||||||
@ -197,8 +265,21 @@ export async function handleGeoAlarmTool(
|
|||||||
args: Record<string, unknown>
|
args: Record<string, unknown>
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "geo_alarm_register":
|
case "geo_alarm_register": {
|
||||||
return apiPost("/v1/alarms/geometries/register", buildGeoRegistrationPayload(args));
|
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":
|
case "geo_alarm_update":
|
||||||
return apiPatch("/v1/alarms/geometries/update", buildGeoRegistrationPayload(args));
|
return apiPatch("/v1/alarms/geometries/update", buildGeoRegistrationPayload(args));
|
||||||
@ -239,14 +320,14 @@ export async function handleGeoAlarmTool(
|
|||||||
return apiGet(`/v1/alarms/geometries/district/${districtId}`);
|
return apiGet(`/v1/alarms/geometries/district/${districtId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "geo_alarm_list_neighbourhoods": {
|
case "geo_alarm_list_neighborhoods": {
|
||||||
const districtId = z.string().parse(args.districtId);
|
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": {
|
case "geo_alarm_get_neighborhood": {
|
||||||
const neighbourhoodId = z.number().parse(args.neighbourhoodId);
|
const neighborhoodId = z.number().parse(args.neighborhoodId);
|
||||||
return apiGet(`/v1/alarms/geometries/neighbourhood/${neighbourhoodId}`);
|
return apiGet(`/v1/alarms/geometries/neighborhood/${neighborhoodId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "../client.js";
|
|||||||
|
|
||||||
const webhookDescription =
|
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 } }";
|
"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 = [
|
export const pointAlarmTools = [
|
||||||
{
|
{
|
||||||
@ -13,6 +15,7 @@ export const pointAlarmTools = [
|
|||||||
type: "object" as const,
|
type: "object" as const,
|
||||||
properties: {
|
properties: {
|
||||||
recipientId: { type: "string", description: "Recipient identifier (US-ASCII)" },
|
recipientId: { type: "string", description: "Recipient identifier (US-ASCII)" },
|
||||||
|
boundary: { type: "object", description: pointBoundaryDescription },
|
||||||
latitude: { type: "number", description: "Center point latitude (-90 to 90)" },
|
latitude: { type: "number", description: "Center point latitude (-90 to 90)" },
|
||||||
longitude: { type: "number", description: "Center point longitude (-180 to 180)" },
|
longitude: { type: "number", description: "Center point longitude (-180 to 180)" },
|
||||||
radius: { type: "number", description: "Alert radius in meters (0 to 50000)" },
|
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'] }",
|
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: {
|
properties: {
|
||||||
registrationId: { type: "string", description: "Registration UUID to update" },
|
registrationId: { type: "string", description: "Registration UUID to update" },
|
||||||
recipientId: { type: "string", description: "Recipient identifier (optional)" },
|
recipientId: { type: "string", description: "Recipient identifier (optional)" },
|
||||||
|
boundary: { type: "object", description: pointBoundaryDescription + " (optional)" },
|
||||||
latitude: { type: "number", description: "New center latitude (optional)" },
|
latitude: { type: "number", description: "New center latitude (optional)" },
|
||||||
longitude: { type: "number", description: "New center longitude (optional)" },
|
longitude: { type: "number", description: "New center longitude (optional)" },
|
||||||
radius: { type: "number", description: "New radius in meters (optional)" },
|
radius: { type: "number", description: "New radius in meters (optional)" },
|
||||||
@ -105,13 +135,77 @@ export const pointAlarmTools = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] 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<string, unknown>): Record<string, unknown> {
|
function buildPointRegistrationPayload(args: Record<string, unknown>): Record<string, unknown> {
|
||||||
const { latitude, longitude, radius, lightningFilter, thunderstormFilter, precipitationFilter, ...rest } = args;
|
const {
|
||||||
|
boundary,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
radius,
|
||||||
|
lightningFilter,
|
||||||
|
thunderstormFilter,
|
||||||
|
precipitationFilter,
|
||||||
|
...rest
|
||||||
|
} = args;
|
||||||
|
|
||||||
const payload: Record<string, unknown> = { ...rest };
|
const payload: Record<string, unknown> = { ...rest };
|
||||||
|
|
||||||
if (latitude !== undefined && longitude !== undefined) {
|
if (boundary && typeof boundary === "object" && !Array.isArray(boundary)) {
|
||||||
|
const pointBoundary = boundary as Record<string, unknown>;
|
||||||
payload.boundary = {
|
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 },
|
point: { lat: latitude, lng: longitude },
|
||||||
radius: radius ?? 0,
|
radius: radius ?? 0,
|
||||||
};
|
};
|
||||||
@ -138,8 +232,21 @@ export async function handlePointAlarmTool(
|
|||||||
args: Record<string, unknown>
|
args: Record<string, unknown>
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "point_alarm_register":
|
case "point_alarm_register": {
|
||||||
return apiPost("/v1/alarms/points/register", buildPointRegistrationPayload(args));
|
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":
|
case "point_alarm_update":
|
||||||
return apiPatch("/v1/alarms/points/update", buildPointRegistrationPayload(args));
|
return apiPatch("/v1/alarms/points/update", buildPointRegistrationPayload(args));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user