2026-02-12 17:12:30 -05:00

260 lines
7.0 KiB
TypeScript

// ============================================================================
// Pipedrive API v1 HTTP Client
// ============================================================================
const BASE_URL = "https://api.pipedrive.com/v1";
export class PipedriveClient {
private apiToken: string;
private baseUrl: string;
private rateLimitRemaining = 100;
private rateLimitReset = 0;
constructor(apiToken?: string, baseUrl?: string) {
this.apiToken = apiToken || process.env.PIPEDRIVE_API_TOKEN || "";
this.baseUrl = baseUrl || process.env.PIPEDRIVE_BASE_URL || BASE_URL;
if (!this.apiToken) {
throw new Error(
"Pipedrive API token is required. Set PIPEDRIVE_API_TOKEN environment variable."
);
}
}
private buildUrl(path: string, query?: Record<string, unknown>): string {
const url = new URL(path, this.baseUrl);
// Add API token to all requests
url.searchParams.set("api_token", this.apiToken);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
}
return url.toString();
}
private get headers(): Record<string, string> {
return {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "pipedrive-mcp/1.0.0",
};
}
private async checkRateLimit(): Promise<void> {
if (this.rateLimitRemaining <= 1 && Date.now() < this.rateLimitReset) {
const waitMs = this.rateLimitReset - Date.now();
if (waitMs > 0 && waitMs < 60000) {
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
}
private updateRateLimit(response: Response): void {
const remaining = response.headers.get("x-ratelimit-remaining");
const reset = response.headers.get("x-ratelimit-reset");
if (remaining) this.rateLimitRemaining = parseInt(remaining, 10);
if (reset) this.rateLimitReset = parseInt(reset, 10) * 1000;
}
async get<T = unknown>(
path: string,
query?: Record<string, unknown>
): Promise<T> {
await this.checkRateLimit();
const url = this.buildUrl(path, query);
const response = await fetch(url, {
method: "GET",
headers: this.headers,
});
this.updateRateLimit(response);
return this.handleResponse<T>(response);
}
async post<T = unknown>(
path: string,
body?: unknown,
query?: Record<string, unknown>
): Promise<T> {
await this.checkRateLimit();
const url = this.buildUrl(path, query);
const options: RequestInit = {
method: "POST",
headers: this.headers,
};
if (body !== undefined) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
this.updateRateLimit(response);
return this.handleResponse<T>(response);
}
async put<T = unknown>(
path: string,
body?: unknown,
query?: Record<string, unknown>
): Promise<T> {
await this.checkRateLimit();
const url = this.buildUrl(path, query);
const options: RequestInit = {
method: "PUT",
headers: this.headers,
};
if (body !== undefined) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
this.updateRateLimit(response);
return this.handleResponse<T>(response);
}
async delete<T = unknown>(
path: string,
query?: Record<string, unknown>
): Promise<T> {
await this.checkRateLimit();
const url = this.buildUrl(path, query);
const response = await fetch(url, {
method: "DELETE",
headers: this.headers,
});
this.updateRateLimit(response);
return this.handleResponse<T>(response);
}
async postFormData<T = unknown>(
path: string,
formData: FormData,
query?: Record<string, unknown>
): Promise<T> {
await this.checkRateLimit();
const url = this.buildUrl(path, query);
const headers: Record<string, string> = {
Accept: "application/json",
"User-Agent": "pipedrive-mcp/1.0.0",
};
const response = await fetch(url, {
method: "POST",
headers,
body: formData,
});
this.updateRateLimit(response);
return this.handleResponse<T>(response);
}
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
let errorBody: string;
try {
const json = await response.json() as any;
errorBody = json.error || json.message || JSON.stringify(json);
} catch {
errorBody = await response.text().catch(() => "Unable to read error body");
}
throw new ApiError(
`Pipedrive API error ${response.status}: ${errorBody}`,
response.status,
errorBody
);
}
try {
const json = await response.json() as any;
// Pipedrive wraps responses in { success: true, data: ... }
if (json.success === false) {
throw new ApiError(
`Pipedrive API error: ${json.error || "Unknown error"}`,
response.status,
JSON.stringify(json)
);
}
return json as T;
} catch (e) {
if (e instanceof ApiError) throw e;
// Some endpoints return 204 No Content
if (response.status === 204) {
return { success: true } as T;
}
throw new ApiError(
`Failed to parse response: ${(e as Error).message}`,
response.status,
""
);
}
}
/**
* Handle paginated responses. Pipedrive uses `start` and `limit` parameters.
*/
async *paginate<T>(
path: string,
query: Record<string, unknown> = {},
limitPerPage = 100
): AsyncGenerator<T[], void, unknown> {
let start = 0;
let hasMore = true;
while (hasMore) {
const result = await this.get<{
success: boolean;
data: T[];
additional_data?: {
pagination?: {
start: number;
limit: number;
more_items_in_collection: boolean;
next_start?: number;
};
};
}>(path, { ...query, start, limit: limitPerPage });
if (result.data && result.data.length > 0) {
yield result.data;
}
const pagination = result.additional_data?.pagination;
hasMore = pagination?.more_items_in_collection ?? false;
start = pagination?.next_start ?? start + limitPerPage;
if (!hasMore || !result.data || result.data.length === 0) {
break;
}
}
}
}
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public body: string
) {
super(message);
this.name = "ApiError";
}
}
/**
* Helper to wrap errors into MCP-compatible tool results
*/
export function err(error: unknown): {
content: Array<{ type: "text"; text: string }>;
isError: true;
} {
const msg =
error instanceof ApiError
? `${error.message}\nStatus: ${error.status}\nBody: ${error.body}`
: error instanceof Error
? error.message
: String(error);
return {
content: [{ type: "text", text: `Error: ${msg}` }],
isError: true,
};
}