260 lines
7.0 KiB
TypeScript
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,
|
|
};
|
|
}
|