Files
oh-my-pi/packages/coding-agent/src/mcp/smithery-connect.ts
T
c22ba6b49c feat: implemented Smithery Registry for MCP search (#96)
* Implemented Smithery MCP Searchable Registry

* refactor(coding-agent): consolidated MCP registry search and improve error handling

- Extracted `parseCommandArgs` and `stripControlChars` utilities to reduce duplication in MCP command controller. Replaced custom URL opening logic with centralized `openPath` utility across OAuth and registry flows.
- Enhanced Smithery auth error handling to gracefully degrade on file read failures with logging instead of throwing, and improved chmod error reporting. Normalized Smithery API base URL to strip trailing slashes.
- Improved registry search pagination to fetch multiple pages until sufficient results are found, with semantic mode support to preserve API relevance ranking. Deduplicated entries by identity key and applied local sorting only in non-semantic mode.

---------

Co-authored-by: can1357 <me@can.ac>
2026-03-03 04:48:23 +01:00

146 lines
4.4 KiB
TypeScript

const SMITHERY_API_BASE_URL = (process.env.SMITHERY_API_URL || "https://api.smithery.ai").replace(/\/+$/, "");
export class SmitheryConnectError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "SmitheryConnectError";
this.status = status;
}
}
type SmitheryNamespace = {
name: string;
};
type SmitheryNamespacesResponse = {
namespaces?: SmitheryNamespace[];
};
type SmitheryConnectionStatus =
| { state: "connected" }
| { state: "auth_required"; authorizationUrl?: string }
| { state: "error"; message: string }
| { state: string; [key: string]: unknown };
export type SmitheryConnection = {
connectionId: string;
mcpUrl: string;
name: string;
status?: SmitheryConnectionStatus;
createdAt?: string;
};
type SmitheryConnectionsResponse = {
connections?: SmitheryConnection[];
nextCursor?: string | null;
};
function buildAuthHeaders(apiKey: string): Headers {
const headers = new Headers();
headers.set("Authorization", `Bearer ${apiKey}`);
headers.set("Content-Type", "application/json");
return headers;
}
function toApiUrl(path: string): string {
return `${SMITHERY_API_BASE_URL}${path}`;
}
async function expectOk(response: Response, context: string): Promise<void> {
if (response.ok) return;
const responseText = await response.text().catch(() => "");
const suffix = responseText ? `: ${responseText}` : "";
throw new SmitheryConnectError(`${context}: ${response.status} ${response.statusText}${suffix}`, response.status);
}
export function getSmitheryApiBaseUrl(): string {
return SMITHERY_API_BASE_URL;
}
export async function listSmitheryNamespaces(apiKey: string): Promise<SmitheryNamespace[]> {
const response = await fetch(toApiUrl("/namespaces"), {
headers: buildAuthHeaders(apiKey),
});
await expectOk(response, "Failed to list Smithery namespaces");
const payload = (await response.json()) as SmitheryNamespacesResponse;
return payload.namespaces ?? [];
}
export async function createSmitheryNamespace(apiKey: string): Promise<SmitheryNamespace> {
const response = await fetch(toApiUrl("/namespaces"), {
method: "POST",
headers: buildAuthHeaders(apiKey),
});
await expectOk(response, "Failed to create Smithery namespace");
return (await response.json()) as SmitheryNamespace;
}
export async function resolveSmitheryNamespace(apiKey: string): Promise<string> {
const namespaces = await listSmitheryNamespaces(apiKey);
if (namespaces.length > 0) {
return namespaces[0]?.name ?? "";
}
const created = await createSmitheryNamespace(apiKey);
return created.name;
}
export async function listSmitheryConnectionsByUrl(
apiKey: string,
namespace: string,
mcpUrl: string,
): Promise<SmitheryConnection[]> {
const endpoint = new URL(toApiUrl(`/connect/${encodeURIComponent(namespace)}`));
endpoint.searchParams.set("mcpUrl", mcpUrl);
const response = await fetch(endpoint.toString(), {
headers: buildAuthHeaders(apiKey),
});
await expectOk(response, "Failed to list Smithery connections");
const payload = (await response.json()) as SmitheryConnectionsResponse;
return payload.connections ?? [];
}
export async function createSmitheryConnection(
apiKey: string,
namespace: string,
params: { mcpUrl: string; name?: string },
): Promise<SmitheryConnection> {
const response = await fetch(toApiUrl(`/connect/${encodeURIComponent(namespace)}`), {
method: "POST",
headers: buildAuthHeaders(apiKey),
body: JSON.stringify({
mcpUrl: params.mcpUrl,
name: params.name,
}),
});
await expectOk(response, "Failed to create Smithery connection");
return (await response.json()) as SmitheryConnection;
}
export async function getSmitheryConnection(
apiKey: string,
namespace: string,
connectionId: string,
): Promise<SmitheryConnection> {
const response = await fetch(
toApiUrl(`/connect/${encodeURIComponent(namespace)}/${encodeURIComponent(connectionId)}`),
{
headers: buildAuthHeaders(apiKey),
},
);
await expectOk(response, "Failed to get Smithery connection");
return (await response.json()) as SmitheryConnection;
}
export async function deleteSmitheryConnection(apiKey: string, namespace: string, connectionId: string): Promise<void> {
const response = await fetch(
toApiUrl(`/connect/${encodeURIComponent(namespace)}/${encodeURIComponent(connectionId)}`),
{
method: "DELETE",
headers: buildAuthHeaders(apiKey),
},
);
await expectOk(response, "Failed to delete Smithery connection");
}