@vitrion/zentria-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ import { PACKAGE_NAME, PACKAGE_VERSION } from "../version.js";
2
+ import { ZentriaError, ZentriaRateLimitError, parseRetryAfter, zentriaErrorFromResponse, } from "./errors.js";
3
+ import { appendQueryParams } from "./query.js";
4
+ export const DEFAULT_BASE_URL = "https://app.zentria.nl";
5
+ export const USER_AGENT = `${PACKAGE_NAME}/${PACKAGE_VERSION}`;
6
+ export function normalizeBaseUrl(raw) {
7
+ return raw
8
+ .replace(/\/+$/, "")
9
+ .replace(/\/api\/public$/i, "")
10
+ .replace(/\/api\/v1$/i, "");
11
+ }
12
+ export class ZentriaClient {
13
+ apiKey;
14
+ baseUrl;
15
+ fetchImpl;
16
+ meCache = null;
17
+ constructor(options = {}) {
18
+ const apiKey = options.apiKey ?? process.env.ZENTRIA_API_KEY ?? process.env.ZENTRIA_API_TOKEN;
19
+ if (!apiKey) {
20
+ throw new Error("ZENTRIA_API_KEY environment variable is required");
21
+ }
22
+ this.apiKey = apiKey;
23
+ this.baseUrl = normalizeBaseUrl(options.baseUrl ?? process.env.ZENTRIA_BASE_URL ?? DEFAULT_BASE_URL);
24
+ this.fetchImpl = options.fetchImpl ?? fetch;
25
+ }
26
+ async request(options) {
27
+ return this.executeRequest(options, false);
28
+ }
29
+ async getMe(force = false) {
30
+ if (!force && this.meCache) {
31
+ return this.meCache;
32
+ }
33
+ const me = (await this.request({ path: "/api/public/me" }));
34
+ this.meCache = me;
35
+ return me;
36
+ }
37
+ async getBoundTeamId() {
38
+ const me = await this.getMe();
39
+ const teamId = me.currentTeam?.id;
40
+ if (teamId === undefined || teamId === null) {
41
+ throw new Error("API key is not bound to a team — GET /api/public/me returned no currentTeam.id");
42
+ }
43
+ return teamId;
44
+ }
45
+ async executeRequest(options, isRetry) {
46
+ const method = options.method ?? "GET";
47
+ const path = appendQueryParams(options.path, options.query);
48
+ const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
49
+ const headers = {
50
+ Authorization: `Bearer ${this.apiKey}`,
51
+ Accept: options.accept ?? "application/json",
52
+ "User-Agent": USER_AGENT,
53
+ };
54
+ let body;
55
+ if (options.body !== undefined && method !== "GET") {
56
+ headers["Content-Type"] = "application/json";
57
+ body = JSON.stringify(options.body);
58
+ }
59
+ const response = await this.fetchImpl(url, { method, headers, body });
60
+ if (response.status === 429 && !isRetry) {
61
+ const retryAfter = parseRetryAfter(response.headers) ?? 1;
62
+ await sleep(retryAfter * 1000);
63
+ return this.executeRequest(options, true);
64
+ }
65
+ if (!response.ok) {
66
+ const errorBody = await readResponseBody(response);
67
+ throw zentriaErrorFromResponse(response.status, errorBody, response.headers);
68
+ }
69
+ if (response.status === 204) {
70
+ return { ok: true };
71
+ }
72
+ return readResponseBody(response);
73
+ }
74
+ }
75
+ async function readResponseBody(response) {
76
+ const text = await response.text();
77
+ if (!text) {
78
+ return undefined;
79
+ }
80
+ try {
81
+ return JSON.parse(text);
82
+ }
83
+ catch {
84
+ return text;
85
+ }
86
+ }
87
+ function sleep(ms) {
88
+ return new Promise((resolve) => setTimeout(resolve, ms));
89
+ }
90
+ export { ZentriaError, ZentriaRateLimitError };
@@ -0,0 +1,93 @@
1
+ export class ZentriaError extends Error {
2
+ status;
3
+ details;
4
+ constructor(status, message, details) {
5
+ super(message);
6
+ this.name = "ZentriaError";
7
+ this.status = status;
8
+ this.details = details;
9
+ }
10
+ }
11
+ export class ZentriaUnauthorizedError extends ZentriaError {
12
+ constructor(message = "Unauthorized — check ZENTRIA_API_KEY") {
13
+ super(401, message);
14
+ this.name = "ZentriaUnauthorizedError";
15
+ }
16
+ }
17
+ export class ZentriaForbiddenError extends ZentriaError {
18
+ constructor(message = "Forbidden — check API key scopes, modules, or team permissions") {
19
+ super(403, message);
20
+ this.name = "ZentriaForbiddenError";
21
+ }
22
+ }
23
+ export class ZentriaNotFoundError extends ZentriaError {
24
+ constructor(message = "Resource not found") {
25
+ super(404, message);
26
+ this.name = "ZentriaNotFoundError";
27
+ }
28
+ }
29
+ export class ZentriaValidationError extends ZentriaError {
30
+ constructor(message, details) {
31
+ super(422, message, details);
32
+ this.name = "ZentriaValidationError";
33
+ }
34
+ }
35
+ export class ZentriaRateLimitError extends ZentriaError {
36
+ retryAfter;
37
+ constructor(message, retryAfter) {
38
+ super(429, message);
39
+ this.name = "ZentriaRateLimitError";
40
+ this.retryAfter = retryAfter;
41
+ }
42
+ }
43
+ export function zentriaErrorFromResponse(status, body, headers) {
44
+ const messageFromBody = extractErrorMessage(body);
45
+ switch (status) {
46
+ case 401:
47
+ return new ZentriaUnauthorizedError(messageFromBody);
48
+ case 403:
49
+ return new ZentriaForbiddenError(messageFromBody);
50
+ case 404:
51
+ return new ZentriaNotFoundError(messageFromBody);
52
+ case 422:
53
+ return new ZentriaValidationError(messageFromBody ?? "Validation failed", body);
54
+ case 429: {
55
+ const retryAfter = parseRetryAfter(headers);
56
+ return new ZentriaRateLimitError(messageFromBody ?? "Rate limit exceeded", retryAfter);
57
+ }
58
+ default:
59
+ return new ZentriaError(status, messageFromBody ?? `Zentria API request failed with status ${status}`, body);
60
+ }
61
+ }
62
+ function extractErrorMessage(body) {
63
+ if (typeof body === "string" && body.trim()) {
64
+ return body;
65
+ }
66
+ if (!body || typeof body !== "object") {
67
+ return undefined;
68
+ }
69
+ const record = body;
70
+ if (typeof record.detail === "string") {
71
+ return record.detail;
72
+ }
73
+ if (typeof record.message === "string") {
74
+ return record.message;
75
+ }
76
+ if (typeof record.title === "string") {
77
+ return record.title;
78
+ }
79
+ if (typeof record.error === "string") {
80
+ return record.error;
81
+ }
82
+ return undefined;
83
+ }
84
+ export function parseRetryAfter(headers) {
85
+ const retryAfter = headers.get("Retry-After");
86
+ if (retryAfter) {
87
+ const seconds = Number.parseInt(retryAfter, 10);
88
+ if (!Number.isNaN(seconds)) {
89
+ return Math.min(seconds, 60);
90
+ }
91
+ }
92
+ return undefined;
93
+ }
@@ -0,0 +1,28 @@
1
+ export function appendQueryParams(path, query) {
2
+ if (!query) {
3
+ return path;
4
+ }
5
+ const params = new URLSearchParams();
6
+ for (const [key, value] of Object.entries(query)) {
7
+ if (value !== undefined && value !== null && value !== "") {
8
+ params.set(key, String(value));
9
+ }
10
+ }
11
+ const qs = params.toString();
12
+ return qs ? `${path}?${qs}` : path;
13
+ }
14
+ export function collectionItems(body) {
15
+ if (Array.isArray(body)) {
16
+ return body;
17
+ }
18
+ if (body && typeof body === "object") {
19
+ const record = body;
20
+ if (Array.isArray(record.member)) {
21
+ return record.member;
22
+ }
23
+ if (Array.isArray(record["hydra:member"])) {
24
+ return record["hydra:member"];
25
+ }
26
+ }
27
+ return [];
28
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@vitrion/zentria-mcp",
3
+ "version": "1.0.0",
4
+ "publishConfig": {
5
+ "access": "public",
6
+ "provenance": true
7
+ },
8
+ "description": "Zentria MCP server — public team API for AI assistants via Model Context Protocol",
9
+ "type": "module",
10
+ "main": "dist/index.js",
11
+ "bin": {
12
+ "zentria-mcp": "dist/index.js"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "start": "node dist/index.js",
20
+ "dev": "tsx src/index.ts",
21
+ "test": "vitest run",
22
+ "inspector": "npx @modelcontextprotocol/inspector node dist/index.js",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "mcp",
27
+ "model-context-protocol",
28
+ "zentria",
29
+ "crm",
30
+ "sales",
31
+ "api"
32
+ ],
33
+ "author": {
34
+ "name": "Vitrion",
35
+ "email": "hello@vitrion.nl"
36
+ },
37
+ "license": "MIT",
38
+ "dependencies": {
39
+ "@modelcontextprotocol/server": "^2.0.0",
40
+ "zod": "^4.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20.0.0",
44
+ "tsx": "^4.0.0",
45
+ "typescript": "^5.0.0",
46
+ "vitest": "^3.0.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=20.0.0"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/vitrionbv/zentria-mcp.git"
54
+ },
55
+ "homepage": "https://github.com/vitrionbv/zentria-mcp#readme",
56
+ "bugs": {
57
+ "url": "https://github.com/vitrionbv/zentria-mcp/issues"
58
+ }
59
+ }