koala73/worldmonitor · error · ProMcpIssueFailed

invalid-user-id

invalid-user-id

Error message

Invalid userId for Pro MCP token issue

What it means

Convex answered HTTP 400 (INVALID_USER_ID) for the internal token issue action: the userId in the request body was empty, missing, or not a usable user reference. issueProMcpTokenForUser maps this to ProMcpIssueFailed kind='invalid-user-id'. It is a caller bug, deterministic for the same input — retrying with the same userId will fail identically.

Solutions

  1. Log and inspect the exact userId being sent in the request body at the call site
  2. Guard the call: only invoke issueProMcpTokenForUser after confirming userId is a non-empty string
  3. Check how the caller derives userId (Clerk subject vs Convex user table ID) and align it with what convex/http.ts validates

Example fix

// before — session hiccup silently produces an empty userId
const userId = session?.user?.id ?? '';
await issueProMcpTokenForUser(userId);

// after — fail fast before hitting Convex
const userId = session?.user?.id;
if (!userId) return new Response('unauthenticated', { status: 401 });
await issueProMcpTokenForUser(userId);
Defensive patterns

Strategy: validation

Validate before calling

function isValidIssueUserId(userId: unknown): userId is string {
  return typeof userId === 'string' && userId.trim().length > 0;
}

if (!isValidIssueUserId(userId)) return new Response('missing user', { status: 401 });

Type guard

function isInvalidUserId(e: unknown): e is ProMcpIssueFailed {
  return e instanceof ProMcpIssueFailed && e.kind === 'invalid-user-id';
}

Try / catch

try {
  await issueProMcpTokenForUser(userId);
} catch (err) {
  if (isInvalidUserId(err)) return new Response('invalid user id', { status: 400 }); // fix input, do not retry
  throw err;
}

Prevention

When it happens

Trigger: issueProMcpTokenForUser('') or issueProMcpTokenForUser(undefined as any) — e.g. the Clerk session resolved without a subject; the caller passed an internal/clerk-prefixed ID where Convex expects its own user table reference; JSON.stringify body dropped userId because the variable was never assigned.

Common situations: OAuth handler reads userId from a session that expired mid-flow, yielding an empty string; a refactor changed the userId source and TypeScript did not catch the undefined because of an any cast; the Convex user row was deleted between grant and issue.

Related errors


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-08-21). Data as JSON: /api/errors/35bff87d8ac94fb3. Report an issue: GitHub.

Appendix: source

Thrown at server/_shared/pro-mcp-token.ts:224

      'network',
      `Convex issue request failed: ${err instanceof Error ? err.message : String(err)}`,
    );
  }

  if (resp.ok) {
    const data = (await resp.json().catch(() => null)) as ProMcpIssueResult | null;
    if (!data || typeof data.tokenId !== 'string' || !data.tokenId) {
      throw new ProMcpIssueFailed('network', 'Convex issue response missing tokenId', resp.status);
    }
    return { tokenId: data.tokenId };
  }

  // Map Convex error responses (see convex/http.ts /api/internal-issue-pro-mcp-token).
  if (resp.status === 403) {
    throw new ProMcpIssueFailed('pro-required', 'Pro entitlement required to issue MCP token', 403);
  }
  if (resp.status === 400) {
    throw new ProMcpIssueFailed('invalid-user-id', 'Invalid userId for Pro MCP token issue', 400);
  }
  // 401 (shared-secret mismatch) and 5xx and any other status → network/transient.
  throw new ProMcpIssueFailed(
    'network',
    `Convex issue returned HTTP ${resp.status}`,
    resp.status,
  );
}

/**
 * Validate a Pro MCP token by tokenId — discriminated-union variant.
 *
 * Returns `{ok:'valid', userId}` if the row exists and is not revoked.
 * Returns `{ok:'revoked'}` if Convex authoritatively returned null
 * (row missing, revoked, or malformed-id). Returns `{ok:'transient'}` on
 * Convex 5xx / network error / timeout / non-JSON — caller can decide
 * whether to fail-closed (per-request validate) or preserve the refresh
 * token (refresh-grant path) instead of consuming it.

View on GitHub (pinned to 7d06c8633d)