transistor-mcp
Advanced tools
+140
| /** | ||
| * Typed error hierarchy for the Transistor.fm API client. | ||
| * | ||
| * `TransistorApiClient` maps every HTTP failure to one of these classes based | ||
| * on status code (see `mapHttpStatusToError`) via a single axios response | ||
| * interceptor registered in its constructor, so callers can branch on error | ||
| * type with `instanceof` instead of parsing status codes out of a generic | ||
| * message. `ToolHandlers` catches `TransistorError` and surfaces | ||
| * `error.message` directly in the MCP `isError` response — each subclass | ||
| * builds its own fully-formatted, human-readable message so that formatting | ||
| * logic lives in one place. | ||
| * | ||
| * Ported from the same pattern in conorbronsdon/podcastindex-mcp (v0.3.0). | ||
| */ | ||
| export class TransistorError extends Error { | ||
| status; | ||
| constructor(message, status) { | ||
| super(message); | ||
| this.name = "TransistorError"; | ||
| this.status = status; | ||
| Object.setPrototypeOf(this, TransistorError.prototype); | ||
| } | ||
| } | ||
| /** HTTP 401/403 — bad, missing, or expired API credentials. */ | ||
| export class AuthenticationError extends TransistorError { | ||
| constructor(detail, status) { | ||
| super(`Authentication error (${status}): ${detail}. Check that TRANSISTOR_API_KEY is correct and has not been revoked in the Transistor.fm dashboard.`, status); | ||
| this.name = "AuthenticationError"; | ||
| Object.setPrototypeOf(this, AuthenticationError.prototype); | ||
| } | ||
| } | ||
| /** HTTP 429 — too many requests against the Transistor.fm API. */ | ||
| export class RateLimitError extends TransistorError { | ||
| constructor(detail, status) { | ||
| super(`Rate limit error (${status}): ${detail}. Slow down requests to the Transistor.fm API and try again shortly.`, status); | ||
| this.name = "RateLimitError"; | ||
| Object.setPrototypeOf(this, RateLimitError.prototype); | ||
| } | ||
| } | ||
| /** HTTP 400 — malformed or invalid request parameters. */ | ||
| export class ValidationError extends TransistorError { | ||
| constructor(detail, status) { | ||
| super(`Validation error (${status}): ${detail}. Check the arguments passed to this tool.`, status); | ||
| this.name = "ValidationError"; | ||
| Object.setPrototypeOf(this, ValidationError.prototype); | ||
| } | ||
| } | ||
| /** HTTP 404 — the requested show, episode, subscriber, or webhook does not exist. */ | ||
| export class NotFoundError extends TransistorError { | ||
| constructor(detail, status) { | ||
| super(`Not found (${status}): ${detail}.`, status); | ||
| this.name = "NotFoundError"; | ||
| Object.setPrototypeOf(this, NotFoundError.prototype); | ||
| } | ||
| } | ||
| /** HTTP 5xx — failure on Transistor.fm's side. */ | ||
| export class ServerError extends TransistorError { | ||
| constructor(detail, status) { | ||
| super(`Server error (${status}): ${detail}. The Transistor.fm API may be experiencing issues — try again later.`, status); | ||
| this.name = "ServerError"; | ||
| Object.setPrototypeOf(this, ServerError.prototype); | ||
| } | ||
| } | ||
| /** | ||
| * Maps an HTTP status code + error detail string to the appropriate typed | ||
| * error. Falls back to the base `TransistorError` (preserving the original | ||
| * "API error (<status>): <detail>" wording) for status codes outside the | ||
| * mapped classes, or when no status is available (e.g. network failures). | ||
| */ | ||
| export function mapHttpStatusToError(status, detail) { | ||
| if (status === 401 || status === 403) | ||
| return new AuthenticationError(detail, status); | ||
| if (status === 429) | ||
| return new RateLimitError(detail, status); | ||
| if (status === 400) | ||
| return new ValidationError(detail, status); | ||
| if (status === 404) | ||
| return new NotFoundError(detail, status); | ||
| if (status !== undefined && status >= 500) | ||
| return new ServerError(detail, status); | ||
| return new TransistorError(`API error (${status ?? "unknown"}): ${detail}`, status); | ||
| } | ||
| /** | ||
| * Pulls a human-readable detail string out of an Axios error response body. | ||
| * | ||
| * Unlike podcastindex-mcp, we have not been able to hit the live Transistor | ||
| * API to confirm the exact shape of its error bodies, so this is a best | ||
| * effort built from two signals already in this codebase: (1) the | ||
| * pre-existing (pre-typed-errors) catch block in tool-handlers.ts read a flat | ||
| * `error.response?.data?.message` field, which may have been an incomplete | ||
| * guess rather than a confirmed shape; (2) every *successful* response | ||
| * shape used throughout api-client.ts and `trimEpisodeResponse` in | ||
| * tool-handlers.ts follows the JSON:API convention (`{ data: { attributes: | ||
| * ... } }`), and JSON:API's own spec defines errors as `{ errors: [ { status, | ||
| * title, detail } ] }` — a strong signal Transistor's error bodies plausibly | ||
| * follow the same convention elsewhere in the API surface, even though we | ||
| * have not observed it directly. | ||
| * | ||
| * To stay safe against both possibilities (and anything else), this checks, | ||
| * in priority order: | ||
| * 1. A plain string body (mirrors podcastindex-mcp, in case Transistor also | ||
| * sends bare-text bodies for some error class). | ||
| * 2. An object with a `.message` string field (preserves the exact | ||
| * behavior of the pre-typed-errors code at tool-handlers.ts ~line 1105). | ||
| * 3. An object with an `.errors` array (JSON:API shape) where each entry | ||
| * may carry `.detail` or `.title`; multiple entries are joined into one | ||
| * string. | ||
| * 4. The provided fallback (e.g. Axios's generic "Request failed with | ||
| * status code NNN") if none of the above match. | ||
| * It never throws on an unexpected shape — an unrecognized body degrades to | ||
| * the fallback rather than crashing the error-mapping path itself. | ||
| */ | ||
| export function extractErrorDetail(responseData, fallback) { | ||
| if (typeof responseData === "string" && responseData.length > 0) | ||
| return responseData; | ||
| if (responseData && typeof responseData === "object") { | ||
| const message = responseData.message; | ||
| if (typeof message === "string" && message.length > 0) | ||
| return message; | ||
| const errors = responseData.errors; | ||
| if (Array.isArray(errors) && errors.length > 0) { | ||
| const parts = errors | ||
| .map((e) => { | ||
| if (e && typeof e === "object") { | ||
| const detail = e.detail; | ||
| if (typeof detail === "string" && detail.length > 0) | ||
| return detail; | ||
| const title = e.title; | ||
| if (typeof title === "string" && title.length > 0) | ||
| return title; | ||
| } | ||
| return undefined; | ||
| }) | ||
| .filter((part) => Boolean(part)); | ||
| if (parts.length > 0) | ||
| return parts.join("; "); | ||
| } | ||
| } | ||
| return fallback; | ||
| } |
+14
-0
| import axios from "axios"; | ||
| import { mapHttpStatusToError, extractErrorDetail } from "./errors.js"; | ||
| import { toTransistorDate } from "./date-utils.js"; | ||
@@ -13,2 +14,15 @@ export class TransistorApiClient { | ||
| }); | ||
| // Centralize HTTP-failure mapping in one place via a response | ||
| // interceptor rather than a shared request helper. This repo's ~20 | ||
| // endpoint methods each call `this.api.get/post/patch/delete` directly | ||
| // (unlike podcastindex-mcp, which funnels every call through a private | ||
| // `get()` helper) — retrofitting a shared helper here would mean | ||
| // touching every method body. An interceptor achieves the same "typed | ||
| // errors mapped in exactly one place" goal with a minimal diff. | ||
| this.api.interceptors.response.use((res) => res, (error) => { | ||
| if (axios.isAxiosError(error)) { | ||
| throw mapHttpStatusToError(error.response?.status, extractErrorDetail(error.response?.data, error.message)); | ||
| } | ||
| throw error; | ||
| }); | ||
| } | ||
@@ -15,0 +29,0 @@ async getAuthenticatedUser() { |
+1
-1
@@ -18,3 +18,3 @@ #!/usr/bin/env node | ||
| name: "transistor-server", | ||
| version: "0.1.0", | ||
| version: "0.4.0", | ||
| }, { | ||
@@ -21,0 +21,0 @@ capabilities: { |
@@ -5,11 +5,17 @@ import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; | ||
| import axios from "axios"; | ||
| import { TransistorError, mapHttpStatusToError, extractErrorDetail } from "./errors.js"; | ||
| /** | ||
| * Extract a concise, human-readable message from an unknown thrown value, | ||
| * preferring the Transistor API's status/message when it is an Axios error. | ||
| * Extract a concise, human-readable message from an unknown thrown value. | ||
| * `TransistorApiClient`'s response interceptor (see api-client.ts) already | ||
| * maps HTTP failures to typed errors before they reach this call site, so | ||
| * the common case is just reading `.message`. The `axios.isAxiosError` | ||
| * branch is a defensive fallback for a raw axios error that somehow | ||
| * bypasses the interceptor. | ||
| */ | ||
| function errorMessage(e) { | ||
| if (e instanceof TransistorError) | ||
| return e.message; | ||
| if (axios.isAxiosError(e)) { | ||
| const status = e.response?.status; | ||
| const apiMsg = e.response?.data?.error ?? e.message; | ||
| return status ? `HTTP ${status}: ${apiMsg}` : apiMsg; | ||
| return mapHttpStatusToError(status, extractErrorDetail(e.response?.data, e.message)).message; | ||
| } | ||
@@ -954,2 +960,16 @@ return e instanceof Error ? e.message : String(e); | ||
| catch (error) { | ||
| if (error instanceof McpError) | ||
| throw error; | ||
| // Typed errors (see ./errors.ts) are thrown by TransistorApiClient's | ||
| // response interceptor with a fully-formatted, status-specific message | ||
| // already baked in. | ||
| if (error instanceof TransistorError) { | ||
| return { | ||
| content: [{ type: "text", text: error.message }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| // Fallback for a raw axios error that reaches this layer without | ||
| // going through TransistorApiClient's interceptor (kept for backward | ||
| // compatibility with the original isError response shape). | ||
| if (axios.isAxiosError(error)) { | ||
@@ -956,0 +976,0 @@ return { |
+6
-3
| { | ||
| "name": "transistor-mcp", | ||
| "version": "0.3.0", | ||
| "description": "MCP server for the Transistor.fm podcast hosting API \u2014 manage shows, episodes, analytics, download summaries, transcripts, subscribers, and webhooks.", | ||
| "version": "0.4.0", | ||
| "description": "MCP server for the Transistor.fm podcast hosting API — manage shows, episodes, analytics, download summaries, transcripts, subscribers, and webhooks.", | ||
| "type": "module", | ||
@@ -42,2 +42,3 @@ "mcpName": "io.github.conorbronsdon/transistor-mcp", | ||
| "lint": "tsc --noEmit", | ||
| "test": "vitest run", | ||
| "watch": "tsc --watch" | ||
@@ -52,3 +53,5 @@ }, | ||
| "@types/node": "^20.11.24", | ||
| "typescript": "^5.3.3" | ||
| "nock": "^14.0.16", | ||
| "typescript": "^5.3.3", | ||
| "vitest": "^4.1.10" | ||
| }, | ||
@@ -55,0 +58,0 @@ "engines": { |
+11
-0
@@ -237,2 +237,13 @@ <div align="center"> | ||
| ### Typed errors | ||
| Tool calls that fail against the Transistor.fm API return a status-specific, | ||
| human-readable message via `isError: true` rather than a generic Axios error. | ||
| Internally, `TransistorApiClient` maps every HTTP failure — authentication | ||
| (401/403), rate limiting (429), validation (400), not found (404), and | ||
| server errors (5xx) — to a typed error class in a single response | ||
| interceptor (see `src/errors.ts`), so the message you see always names the | ||
| failure mode and, for auth errors, points at the `TRANSISTOR_API_KEY` | ||
| environment variable. | ||
| ## Important Notes | ||
@@ -239,0 +250,0 @@ |
No tests
QualityPackage does not have any tests. This is a strong signal of a poorly maintained or low quality package.
94672
10.85%9
12.5%1736
11.14%1
-50%559
2.01%4
100%