// Reusable Basecamp client utilities for your MCP server.
// Node 18+ (global fetch)
export const ACCOUNT_ID = (process.env.BASECAMP_ACCOUNT_ID || "").trim();
let ACCESS_TOKEN = (process.env.BASECAMP_ACCESS_TOKEN || "").trim(); // mutable on refresh
const REFRESH_TOKEN = (process.env.BASECAMP_REFRESH_TOKEN || "").trim();
const CLIENT_ID = (process.env.BASECAMP_CLIENT_ID || "").trim();
const CLIENT_SECRET = (process.env.BASECAMP_CLIENT_SECRET || "").trim();
export const USER_AGENT =
process.env.BASECAMP_USER_AGENT || "mcp-basecamp (you@example.com)";
export const BASE_URL = `https://3.basecampapi.com/${ACCOUNT_ID}`;
const TOKEN_URL =
"https://launchpad.37signals.com/authorization/token?type=web_server";
function ensureConfig() {
if (!ACCOUNT_ID || !ACCESS_TOKEN) {
throw new Error(
"Set BASECAMP_ACCOUNT_ID and BASECAMP_ACCESS_TOKEN (or REFRESH_TOKEN + CLIENT_ID + CLIENT_SECRET)."
);
}
}
export async function refreshAccessToken(): Promise<string | null> {
if (!REFRESH_TOKEN || !CLIENT_ID || !CLIENT_SECRET) return null;
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "refresh",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
refresh_token: REFRESH_TOKEN,
}),
});
if (!res.ok)
throw new Error(`Refresh failed: ${res.status} ${await res.text()}`);
const data = await res.json();
ACCESS_TOKEN = data.access_token || ACCESS_TOKEN;
return ACCESS_TOKEN;
}
export type BCResponse<T> = { data: T; headers: Headers };
export async function bcRequestWithHeaders<T = any>(
method: string,
path: string,
body?: unknown,
params?: Record<string, string | number | boolean>
): Promise<BCResponse<T>> {
ensureConfig();
const url = new URL(BASE_URL + path);
if (params)
for (const [k, v] of Object.entries(params))
url.searchParams.set(k, String(v));
const headers: Record<string, string> = {
Authorization: `Bearer ${ACCESS_TOKEN}`,
"User-Agent": USER_AGENT,
Accept: "application/json",
};
if (body) headers["Content-Type"] = "application/json";
let res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get("Retry-After") ?? "2");
await new Promise((r) => setTimeout(r, retryAfter * 1000));
res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
}
if (res.status === 401) {
const newToken = await refreshAccessToken();
if (newToken) {
headers.Authorization = `Bearer ${newToken}`;
res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
}
}
if (!res.ok)
throw new Error(`${res.status} ${res.statusText}: ${await res.text()}`);
const data = res.status === 204 ? (null as any) : await res.json();
return { data, headers: res.headers };
}
export async function bcRequest<T = any>(
method: string,
path: string,
body?: unknown,
params?: Record<string, string | number | boolean>
): Promise<T> {
const { data } = await bcRequestWithHeaders<T>(method, path, body, params);
return data;
}
export function parseNextPage(linkHeader?: string | null): number | null {
if (!linkHeader) return null;
const m = linkHeader.match(/<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="next"/);
return m ? Number(m[1]) : null;
}
// (Optional) expose a setter if you ever want to inject a token manually
export function setAccessToken(token: string) {
ACCESS_TOKEN = token;
}