Skip to content

Commit f669a5b

Browse files
committed
fix: preserve InfluxDB error bodies on the write path, fix zod packaging
handleWriteError discarded InfluxDB's response body and threw fixed strings for 400/401/403/413/422, so 3.11's duplicate-tag-key rejection never returned the tag name to the caller. Extract error normalization into a shared error-resolution.service.ts (normalizeError, resolveErrorMessage), add a 503 arm phrased as retryable, serialize non-string bodies instead of rendering [object Object], and normalize the cloud SDK's HttpError shape so Cloud Dedicated/Serverless reach the same status arms as the axios-based paths. Also moves zod from devDependencies to dependencies — it's imported at runtime by every tool category module, so npm i --omit=dev previously shipped a server that couldn't start. Verified against live Core and Enterprise 3.11.2 (including a stopped node on a 3-node Enterprise cluster). Bumps to 1.4.1. Claude-Session: https://claude.ai/code/session_01JW2yQimGT2zarCwquDjKK8
1 parent d981377 commit f669a5b

11 files changed

Lines changed: 238 additions & 243 deletions

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,35 @@ All notable changes to the official InfluxDB MCP Server will be documented in th
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.4.1] - 2026-09-01
9+
10+
### Fixed
11+
12+
- **Write-path errors now preserve InfluxDB's error body.**
13+
`write_line_protocol` discarded InfluxDB's response body on 400/401/403/413/422 and threw a fixed generic string instead.
14+
A duplicate-tag-key write (rejected by InfluxDB 3.11+ before it reaches storage) now returns an error naming the tag.
15+
For example, `Bad request: invalid line protocol - multiple instances of 'region' tag found`, instead of `Bad request: Invalid line protocol format or parameters`.
16+
Verified against live Core and Enterprise 3.11.2.
17+
- **Added a 503 arm to the write error handler**, phrased as retryable (`Service temporarily unavailable, retry the write: ...`), so callers can distinguish a transient failure from a bad request.
18+
Previously any unhandled status, including 503, fell through to the generic fallback.
19+
- **The write error fallback now serializes a parsed JSON body** instead of interpolating it directly.
20+
The unserialized version previously rendered as `Failed to write data to database 'x': [object Object]`.
21+
- **Cloud Dedicated/Serverless write errors now classify correctly.**
22+
The InfluxDB SDK throws `HttpError` (`error.statusCode`, `error.json`/`error.body`), a different shape than the axios-based Core/Enterprise/Clustered path (`error.response.status`/`.data`).
23+
Every status branch previously missed on the SDK shape, so cloud writes always fell through to the generic fallback even for a 400 or 401.
24+
Both shapes now normalize to one internal form before branching.
25+
- **`zod` moved from `devDependencies` to `dependencies`.**
26+
It's imported at runtime by `src/tools/index.ts` and every tool category module.
27+
A clean `npm i --omit=dev` install, or any consumer installing the published package, previously got a server that failed to start.
28+
- **8 transitive dependency vulnerabilities resolved** via `npm audit fix` (2 moderate, 6 high), all transitive through `eslint`/`vitest`.
29+
No direct runtime dependency was affected.
30+
31+
### Added
32+
33+
- `src/services/error-resolution.service.ts`: shared error normalization (`normalizeError`, `resolveErrorMessage`) extracted from the write-path fix.
34+
It follows the same body-resolution order `handleQueryError` already used.
35+
It's available for the query path and the other services with their own ad hoc body-preserving variants (`token-management.service.ts`, `database-management.service.ts`, `cloud-token-management.service.ts`) to adopt in a later pass.
36+
837
## [1.4.1-test.1] - 2026-07-30
938

1039
### Changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,34 @@ await mcp.update_database({
443443
- For connection issues, check your environment variables and InfluxDB instance status.
444444
- For advanced configuration, see the comments in the example `.env` and MCP config files.
445445

446+
### Write errors
447+
448+
`write_line_protocol` surfaces InfluxDB's own error text, not a generic
449+
message. If InfluxDB rejects a write — a duplicate tag key, an
450+
unauthenticated token, a payload over the size limit — the tool error
451+
includes the specific reason, for example:
452+
453+
```
454+
Bad request: invalid line protocol - multiple instances of 'region' tag found
455+
```
456+
457+
A `503` reaching this server is phrased as retryable
458+
(`Service temporarily unavailable, retry the write: ...`) — safe to retry
459+
the write. Any other status is not.
460+
461+
### InfluxDB 3.11 compatibility
462+
463+
Verified against InfluxDB 3.11.2 Core and Enterprise (including a
464+
multi-node Enterprise cluster). Core and Enterprise write through
465+
`POST /api/v3/write_lp`, which 3.11's write-availability changes for the
466+
legacy `/api/v2/write` endpoint do not affect; only `clustered` calls
467+
`/api/v2/write`. Query and schema-discovery tools behave the same whether
468+
the target database is on Parquet (Core, or Enterprise before an upgrade)
469+
or PachaTree (Enterprise 3.11+ by default, or after
470+
`--upgrade-pacha-tree`) — new `system.pt_*` tables are excluded from
471+
`get_measurements`/`get_measurement_schema` results by the same
472+
`table_schema = 'iox'` filter that already excludes other system tables.
473+
446474
---
447475

448476
## License
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# ADR 0002: Shared error resolution for the write and query paths
2+
3+
- Status: Accepted (implemented for the write path in the 1.4.1 patch)
4+
- Date: 2026-09-01
5+
6+
## Context
7+
8+
`WriteService.handleWriteError` and `QueryService.handleQueryError`
9+
(`src/services/write.service.ts`, `src/services/query.service.ts`) both
10+
convert a thrown HTTP error into the error message returned to the MCP
11+
client. The two implementations diverged. `handleQueryError` resolves the actual InfluxDB
12+
error body (`data.message``data.error` → string body → `statusText`
13+
`error.message`). `handleWriteError` matched only on
14+
`error.response?.status` and threw a fixed string per status; the response
15+
body was discarded. Its fallback interpolated `error.response?.data`
16+
directly, which rendered a parsed JSON body as `[object Object]`.
17+
18+
InfluxDB 3.11 rejects a duplicate-tag-key write with a body that names the
19+
tag, nested under `data.data[].error_message`. The fixed 400 string
20+
discarded that body, so the tag name was not returned to the client. Neither
21+
handler had a 503 arm. The two write-capable transports throw different
22+
error shapes: axios (`error.response.status`/`.data`) for
23+
Core/Enterprise/Clustered, and the InfluxDB SDK's `HttpError`
24+
(`error.statusCode`, `error.json`/`.body`) for Cloud Dedicated/Serverless.
25+
`handleWriteError` matched only the axios shape, so cloud write errors
26+
always fell through to the fallback.
27+
28+
Three other services preserve error bodies with their own separate
29+
implementations: `token-management.service.ts`,
30+
`database-management.service.ts`, `cloud-token-management.service.ts`.
31+
32+
## Decision
33+
34+
Extract the shared logic into `src/services/error-resolution.service.ts`:
35+
36+
- `normalizeError(error)` converts the axios shape and the SDK `HttpError`
37+
shape to one `{ status, body }` form.
38+
- `resolveErrorMessage(body, fallback)` resolves the error message from a
39+
body: the write path's partial-write shapes first, then the query path's
40+
existing resolution order, then `fallback`.
41+
42+
`handleWriteError` calls both and switches on the normalized status,
43+
including a new `503` arm phrased as retryable. `handleQueryError` and the
44+
three token/database services are unchanged in this patch. The helper's
45+
default resolution order already matches `handleQueryError`'s current
46+
behavior, so migrating it, and the other three services, is deferred to a
47+
later change.
48+
49+
## Consequences
50+
51+
- One function defines the write path's error-body resolution for all five
52+
product types, instead of five status arms each with its own field
53+
access.
54+
- A future status code that needs a body-preserving arm extends
55+
`resolveErrorMessage`'s resolution order once, for both paths.
56+
- `handleQueryError` and the three token/database services keep their own
57+
implementations. That is a known follow-up, not a regression introduced
58+
by this change.

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@influxdata/influxdb3-mcp-server",
3-
"version": "1.4.1-test.1",
3+
"version": "1.4.1",
44
"description": "Official InfluxDB MCP server for Model Context Protocol integration",
55
"license": "(Apache-2.0 OR MIT)",
66
"private": false,
@@ -63,7 +63,8 @@
6363
"@influxdata/influxdb3-client": "1.4.0",
6464
"@modelcontextprotocol/sdk": "1.30.0",
6565
"axios": "1.18.0",
66-
"dotenv": "16.6.1"
66+
"dotenv": "16.6.1",
67+
"zod": "3.25.76"
6768
},
6869
"devDependencies": {
6970
"@eslint/js": "9.39.4",
@@ -73,7 +74,6 @@
7374
"eslint": "9.39.4",
7475
"prettier": "3.8.1",
7576
"typescript": "5.9.3",
76-
"vitest": "4.1.1",
77-
"zod": "3.25.76"
77+
"vitest": "4.1.1"
7878
}
7979
}

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function loadConfig(): McpServerConfig {
4848
},
4949
server: {
5050
name: "influxdb-mcp-server",
51-
version: "1.4.1-test.1",
51+
version: "1.4.1",
5252
},
5353
tools: {
5454
profile:
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Shared error normalization for the write and query paths.
3+
*
4+
* Two transports throw two different error shapes: axios (`error.response.*`)
5+
* for the Core/Enterprise/Clustered HTTP paths, and the InfluxDB SDK's
6+
* `HttpError` (`error.statusCode`, `error.json`/`error.body`) for the
7+
* Cloud Dedicated/Serverless paths. `normalizeError` collapses both to one
8+
* shape so status-branching code doesn't need to know which transport threw.
9+
*/
10+
11+
export interface NormalizedError {
12+
status: number | undefined;
13+
body: unknown;
14+
}
15+
16+
export function normalizeError(error: any): NormalizedError {
17+
if (error?.response) {
18+
return { status: error.response.status, body: error.response.data };
19+
}
20+
if (typeof error?.statusCode === "number") {
21+
return { status: error.statusCode, body: error.json ?? error.body };
22+
}
23+
return { status: undefined, body: undefined };
24+
}
25+
26+
/**
27+
* Resolve the actionable message out of an InfluxDB error body.
28+
*
29+
* Write-path partial-write bodies nest the actionable detail under
30+
* `data.data[].error_message` (when `accept_partial=true`, an array) or
31+
* `data.data.error_message` (when `accept_partial=false`, an object) — one
32+
* level below `data.error`, which is only ever the generic "partial write of
33+
* line protocol occurred". Those two arms are checked first; query-path
34+
* bodies never have a `data.data`, so they fall through unaffected.
35+
*/
36+
export function resolveErrorMessage(body: unknown, fallback: string): string {
37+
if (body && typeof body === "object") {
38+
const data = body as Record<string, unknown>;
39+
40+
if (Array.isArray(data.data)) {
41+
const first = data.data[0] as Record<string, unknown> | undefined;
42+
if (typeof first?.error_message === "string") return first.error_message;
43+
} else if (data.data && typeof data.data === "object") {
44+
const nested = data.data as Record<string, unknown>;
45+
if (typeof nested.error_message === "string") return nested.error_message;
46+
}
47+
48+
if (typeof data.message === "string") return data.message;
49+
if (typeof data.error === "string") return data.error;
50+
return fallback;
51+
}
52+
53+
if (typeof body === "string") return body;
54+
return fallback;
55+
}

src/services/write.service.ts

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77

88
import { BaseConnectionService } from "./base-connection.service.js";
99
import { InfluxProductType } from "../helpers/enums/influx-product-types.enum.js";
10+
import {
11+
normalizeError,
12+
resolveErrorMessage,
13+
} from "./error-resolution.service.js";
1014

1115
export type Precision = "nanosecond" | "microsecond" | "millisecond" | "second";
1216

@@ -191,25 +195,27 @@ export class WriteService {
191195
* Centralized error handler for write methods
192196
*/
193197
private handleWriteError(error: any, database: string): never {
194-
if (error.response?.status === 400) {
195-
throw new Error(
196-
`Bad request: Invalid line protocol format or parameters`,
197-
);
198-
} else if (error.response?.status === 401) {
199-
throw new Error("Unauthorized: Check your InfluxDB token permissions");
200-
} else if (error.response?.status === 403) {
201-
throw new Error(
202-
"Access denied: Insufficient permissions for database operations",
203-
);
204-
} else if (error.response?.status === 413) {
205-
throw new Error(
206-
"Request entity too large: Reduce the size of your line protocol data",
207-
);
208-
} else if (error.response?.status === 422) {
209-
throw new Error("Unprocessable entity: Invalid line protocol syntax");
198+
const { status, body } = normalizeError(error);
199+
const message = resolveErrorMessage(body, error.message);
200+
switch (status) {
201+
case 400:
202+
throw new Error(`Bad request: ${message}`);
203+
case 401:
204+
throw new Error(`Unauthorized: ${message}`);
205+
case 403:
206+
throw new Error(`Access denied: ${message}`);
207+
case 413:
208+
throw new Error(`Request entity too large: ${message}`);
209+
case 422:
210+
throw new Error(`Unprocessable entity: ${message}`);
211+
case 503:
212+
throw new Error(
213+
`Service temporarily unavailable, retry the write: ${message}`,
214+
);
215+
default:
216+
throw new Error(
217+
`Failed to write data to database '${database}': ${message}`,
218+
);
210219
}
211-
throw new Error(
212-
`Failed to write data to database '${database}': ${error.response?.data || error.message}`,
213-
);
214220
}
215221
}

tests/packaging.test.ts

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
/**
2-
* Packaging — `zod` is a misdeclared runtime dependency.
2+
* Packaging — every runtime import must be a declared dependency.
33
*
44
* `zod` is imported at runtime by `src/tools/index.ts` and by every
5-
* `src/tools/categories/*.tools.ts`, but is declared in `devDependencies`. A
6-
* clean `npm i --omit=dev` — or any consumer installing the published package
7-
* — gets a server that cannot start.
5+
* `src/tools/categories/*.tools.ts`. It used to be declared only in
6+
* `devDependencies`, so a clean `npm i --omit=dev` — or any consumer
7+
* installing the published package — got a server that couldn't start.
88
*
99
* The check is written as an invariant over every runtime import rather than
10-
* as a check on `zod` specifically, so it keeps working once `zod` moves to
11-
* `dependencies` and catches the next occurrence.
10+
* as a check on `zod` specifically, so it catches the next occurrence too.
1211
*/
1312

1413
import { describe, it, expect } from "vitest";
@@ -69,34 +68,15 @@ describe("runtime imports are declared as dependencies", () => {
6968
expect([...imports.keys()]).toContain("zod");
7069
});
7170

72-
it("every runtime import except zod is declared in dependencies", () => {
73-
// Current state. When zod moves to dependencies, this test fails and
74-
// should be replaced by the assertion below.
75-
const missing = [...imports.keys()].filter((n) => !declaredRuntime.has(n));
76-
77-
expect(missing).toEqual(["zod"]);
78-
});
79-
80-
it("zod is imported at runtime but declared only in devDependencies", () => {
81-
const zodImporters = imports.get("zod") ?? [];
82-
83-
expect(zodImporters.length).toBeGreaterThan(0);
84-
expect(zodImporters).toContain("src/tools/index.ts");
85-
expect(declaredDev.has("zod")).toBe(true);
86-
expect(declaredRuntime.has("zod")).toBe(false);
87-
});
88-
89-
it("the published package ships build/, so the import survives to consumers", () => {
71+
it("the published package ships build/, so a missing dependency reaches consumers", () => {
9072
// Not a hypothetical: `files` includes `build`, and the compiled output
91-
// keeps the bare `zod` specifier. The failure lands on the consumer.
73+
// keeps every bare specifier. A misdeclared package lands on the consumer.
9274
expect(pkg.files).toContain("build");
9375
expect(pkg.main).toBe("./build/index.js");
9476
});
9577
});
9678

97-
describe.skip("zod is a runtime dependency", () => {
98-
// Un-skip when zod moves to dependencies, and delete the two
99-
// characterization tests above that assert the opposite.
79+
describe("zod is a runtime dependency", () => {
10080
const imports = runtimeImports();
10181

10282
it("no runtime import is missing from dependencies", () => {

0 commit comments

Comments
 (0)