Skip to main content
Glama
basecamp.ts3.68 kB
// 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; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/craigashields/basecamp-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server