mcp-didi: MCP para Open Notebook self-hosted (Didi) via REST + Bearer token
This commit is contained in:
+146
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Cliente REST mínimo para a API do Open Notebook self-hosted ("Didi",
|
||||
* didi.descomplicar.pt) — Bearer token, JSON, base path `/api`.
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
type QueryValue = string | number | boolean | undefined;
|
||||
type Query = Record<string, QueryValue>;
|
||||
|
||||
/** Um erro Pydantic de validação (422), um item de `[{type,loc,msg,...}]`. */
|
||||
interface PydanticValidationError {
|
||||
type?: string;
|
||||
loc?: Array<string | number>;
|
||||
msg?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erro HTTP não-2xx da API do Open Notebook. Preserva o `detail` bruto
|
||||
* (string, ou array de erros Pydantic num 422) para diagnóstico — nunca
|
||||
* o engole, é a única forma de perceber um 422 de schema (ex. enum
|
||||
* `provider` inválido).
|
||||
*/
|
||||
export class DidiApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public detail: unknown,
|
||||
public path: string,
|
||||
) {
|
||||
super(`Open Notebook API ${status} em ${path}: ${DidiApiError.formatDetail(detail)}`);
|
||||
this.name = "DidiApiError";
|
||||
}
|
||||
|
||||
private static formatDetail(detail: unknown): string {
|
||||
if (detail === undefined || detail === null) return "sem detalhe";
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return detail
|
||||
.map((e: PydanticValidationError) => {
|
||||
if (e && typeof e === "object" && ("loc" in e || "msg" in e)) {
|
||||
const loc = Array.isArray(e.loc) ? e.loc.join(".") : "?";
|
||||
return `[${loc}] ${e.msg ?? JSON.stringify(e)}`;
|
||||
}
|
||||
return JSON.stringify(e);
|
||||
})
|
||||
.join("; ");
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(detail);
|
||||
} catch {
|
||||
return String(detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(baseUrl: string, path: string, query?: Query): string {
|
||||
const url = new URL(`${baseUrl.replace(/\/+$/, "")}/api${path}`);
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export class DidiClient {
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
private readonly token: string,
|
||||
) {}
|
||||
|
||||
private async request<T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
opts?: { query?: Query; body?: unknown },
|
||||
): Promise<T> {
|
||||
const url = buildUrl(this.baseUrl, path, opts?.query);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: opts?.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
throw new DidiApiError(0, `Timeout após ${REQUEST_TIMEOUT_MS / 1000}s`, path);
|
||||
}
|
||||
throw new DidiApiError(0, err instanceof Error ? err.message : String(err), path);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const raw = await res.text();
|
||||
let parsed: unknown = undefined;
|
||||
if (raw.length > 0) {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = raw;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const detail =
|
||||
parsed && typeof parsed === "object" && "detail" in (parsed as Record<string, unknown>)
|
||||
? (parsed as Record<string, unknown>).detail
|
||||
: parsed;
|
||||
throw new DidiApiError(res.status, detail, path);
|
||||
}
|
||||
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
get<T>(path: string, query?: Query): Promise<T> {
|
||||
return this.request<T>("GET", path, { query });
|
||||
}
|
||||
|
||||
post<T>(path: string, body?: unknown, query?: Query): Promise<T> {
|
||||
return this.request<T>("POST", path, { body, query });
|
||||
}
|
||||
|
||||
put<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.request<T>("PUT", path, { body });
|
||||
}
|
||||
|
||||
/**
|
||||
* `query` é uma extensão ao contrato mínimo — alguns deletes da API
|
||||
* precisam de query params (`delete_notebook?delete_exclusive_sources=`,
|
||||
* `delete_credential?migrate_to=`).
|
||||
*/
|
||||
delete<T>(path: string, query?: Query): Promise<T> {
|
||||
return this.request<T>("DELETE", path, { query });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user