paperclipai/paperclip · error · RailwayError

railway_unknown_tool

railway_unknown_tool

Error message

Unknown Railway operation.

What it means

`call()` strips the `paperclip-railway-` prefix and checks the remainder against the known operation schema map (list-projects, list-services, list-environments, service-status, list-deployments, deployment-status, read-logs, redeploy, restart, rollback, run-command). Calls with a name that either doesn't carry the prefix or doesn't map to a known operation throw `railway_unknown_tool` with HTTP 400.

Solutions

  1. Call only names taken verbatim from the exported RAILWAY_TOOLS array (name field)
  2. Check the name starts with `paperclip-railway-` and matches a kebab-case operation exactly
  3. If the desired action isn't in the direct bridge (e.g. create-project), use Railway's hosted MCP connection or dashboard instead

Example fix

// before
await client.call("railway-list-projects", args); // missing prefix
// after
await client.call("paperclip-railway-list-projects", args);
Defensive patterns

Strategy: validation

Validate before calling

import { RAILWAY_TOOL_PREFIX, RAILWAY_TOOLS } from "../services/railway.js";
const known = new Set(RAILWAY_TOOLS.map((t) => t.name));
if (!known.has(toolName)) throw new Error(`unknown tool: ${toolName}`);

Type guard

function isRailwayToolName(v: unknown): v is `${typeof RAILWAY_TOOL_PREFIX}${string}` {
  return typeof v === "string" && v.startsWith(RAILWAY_TOOL_PREFIX) && knownOperations.has(v.slice(RAILWAY_TOOL_PREFIX.length));
}

Try / catch

try {
  return await client.call(name, args);
} catch (e) {
  if (isRailwayError(e) && e.code === "railway_unknown_tool") {
    const resolved = RAILWAY_TOOLS.find((t) => normalizeRailwayToolName(t.name) === normalizeRailwayToolName(name));
    if (resolved) return client.call(resolved.name, args);
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling a tool name without the `paperclip-railway-` prefix; a typo like `paperclip-railway-list-projects ` or `paperclip-railway-service-status-v2`; a camelCase variant not covered by the normalizer; a tool from Railway's hosted MCP catalog that isn't part of the direct bridge.

Common situations: Hardcoded tool names that drifted from the current catalog; agents inventing operations like `create-service` or `delete-project` (not in the direct schema); name casing mismatch when calling directly instead of via the advertised RAILWAY_TOOLS list.

Understand the failure class

Background: UnsupportedOperationException and "is not supported" errors: when a library deliberately refuses a call — this error's family across 30 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/ccda2650a9facede. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/railway.ts:254

    return data.serviceInstance;
  }

  async function validateDeployment(args: Record<string, any>) {
    const data = await query(RAILWAY_QUERIES.deployment, { deploymentId: args.deploymentId });
    const d = record(data.deployment);
    if (d.id !== args.deploymentId || d.projectId !== args.projectId || d.environmentId !== args.environmentId || d.serviceId !== args.serviceId) throw new RailwayError("railway_target_mismatch", "The deployment does not belong to the selected Railway target.", 403);
    return d;
  }

  return {
    async probe(workspaceId: string) {
      if (!id.safeParse(workspaceId).success) throw new RailwayError("railway_workspace_required", "Choose an authorized Railway workspace before checking API access.", 400);
      await query(RAILWAY_QUERIES.projects, { workspaceId, first: 1 });
    },
    async call(name: string, parameters: unknown): Promise<unknown> {
      if (isRailwayToolBlocked(name)) throw new RailwayError("railway_action_blocked", "This Railway action cannot bind its effects to an approved target. Use redeploy, restart, or rollback for an existing deployment.", 403);
      const operation = name.slice(RAILWAY_TOOL_PREFIX.length) as Operation;
      if (!name.startsWith(RAILWAY_TOOL_PREFIX) || !Object.hasOwn(schema, operation)) throw new RailwayError("railway_unknown_tool", "Unknown Railway operation.", 400);
      const parsed = schema[operation].safeParse(parameters);
      if (!parsed.success) throw new RailwayError("railway_invalid_arguments", "Invalid Railway operation arguments. Use the exact IDs and limits in the action schema.", 400);
      const args = parsed.data as Record<string, any>;
      let result: unknown;
      if (operation === "list-projects") result = await query(RAILWAY_QUERIES.projects, args);
      else if (operation === "list-services" || operation === "list-environments") result = await query(operation === "list-services" ? RAILWAY_QUERIES.services : RAILWAY_QUERIES.environments, args);
      else {
        const instance = await validateTarget(args);
        const deployment = args.deploymentId ? await validateDeployment(args) : null;
        switch (operation) {
          case "service-status": result = instance; break;
          case "deployment-status": result = deployment; break;
          case "list-deployments": result = await query(RAILWAY_QUERIES.deployments, { input: { projectId: args.projectId, environmentId: args.environmentId, serviceId: args.serviceId }, first: args.first, after: args.after }); break;
          case "read-logs": {
            if (args.startDate && args.endDate && Date.parse(args.startDate) > Date.parse(args.endDate)) throw new RailwayError("railway_invalid_arguments", "Log start time must precede end time.", 400);
            const data = await query(args.kind === "build" ? RAILWAY_QUERIES.buildLogs : RAILWAY_QUERIES.runtimeLogs, { deploymentId: args.deploymentId, limit: args.limit, startDate: args.startDate, endDate: args.endDate, filter: args.filter });
            const lines = data[args.kind === "build" ? "buildLogs" : "deploymentLogs"];
            if (!Array.isArray(lines)) throw new RailwayError("railway_invalid_response", "Railway returned invalid log data.");

View on GitHub (pinned to 3f1d897a7c)