Ctrl K
ComunidadGuías…

src/lib/mcp/handler.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type { McpScope, User } from "@prisma/client";
import {
  metaFor,
  promptsFor,
  promptsByName,
  splitLinks,
  splitNotice,
  toolsFor,
  toolsByName,
  type McpContext,
} from "@/lib/mcp/tools";
import { scopeBlurb, scopeLabel } from "@/lib/mcp/scopes";
import { meIsEmpty } from "@/lib/data/me";
import {
  CRITICAL_RULES,
  HEAD_BUDGET,
  identityFor,
  rulesAllowance,
  standingRulesFor,
} from "@/lib/mcp/briefing-head";
import { recordSystemEvent } from "@/lib/data/system";
import { dispatchTool } from "@/lib/mcp/dispatch";
import { isAdmin, type McpCaller } from "@/lib/auth";
import { getSettings } from "@/lib/settings";

/**
 * A small, dependency-light implementation of the MCP Streamable HTTP transport.
 *
 * Written by hand rather than wired through the SDK's Node transport because
 * Next.js route handlers speak the Web Request/Response API. Stateless: every
 * POST is self-contained, so there are no sessions to lose across restarts or
 * replicas — and every request re-resolves its user from the token, so
 * suspending someone takes effect immediately.
 */

/**
 * The two eras this server speaks, and why it speaks both.
 *
 * MCP changed shape in revision 2026-07-28: the `initialize` handshake is gone,
 * every request declares its own version, every result carries a `resultType`,
 * and `server/discover` replaces the handshake as the way in. The old shape did
 * not become invalid — every shipping client still speaks it — so this server
 * answers either, chosen per request rather than per connection.
 *
 * That choice is free here and would not be on a stateful server: the modern
 * revision made statelessness the spec-native shape, which is the architecture
 * this file already had. Era is a pure function of one message.
 *
 * MODERN_PROTOCOL_VERSION is deliberately not in LEGACY_PROTOCOL_VERSIONS. A
 * client that sends `initialize` is by definition speaking the old shape, so it
 * negotiates from the old list and gets 2025-06-18 back even if it asked for
 * something newer — which is what the old rules say to do and keeps the two
 * paths from crossing.
 *
 * 2025-11-25 exists and is not listed. Nobody has read its changelog, and
 * claiming a revision you have not read is how a server answers wrongly with
 * confidence.
 */
const MODERN_PROTOCOL_VERSION = "2026-07-28";
const LEGACY_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
const LATEST_PROTOCOL_VERSION = "2025-06-18";

/** Everything this server will answer to, newest first. */
const SUPPORTED_PROTOCOL_VERSIONS = [MODERN_PROTOCOL_VERSION, ...LEGACY_PROTOCOL_VERSIONS];

type Era = "modern" | "legacy";

/** The `_meta` keys the modern revision reserves on a request. */
const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion";
const META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";

const SERVER_INFO = {
  name: "hired",
  title: "Hired",
  version: "2.0.0",
};

/**
 * The briefing every client is handed on connect — and the order it is in.
 *
 * ORDER IS LOAD-BEARING HERE, because at least one major client truncates it.
 * Claude Code "truncates tool descriptions and server instructions at 2KB
 * each. Keep them concise to avoid truncation, and put critical details near
 * the start." (code.claude.com/docs/en/mcp). Everything past roughly two
 * thousand characters is cut with no warning to anybody.
 *
 * That used to be catastrophic rather than merely lossy: this block opened
 * with a tour of the five areas and appended the person's own standing rules
 * LAST, so the one thing that must never be missing was the first thing
 * dropped. The rules are now the head and the tour is the tail.
 *
 * So the block is built in two parts:
 *   HEAD — who you are connected as, their standing rules, and the handful of
 *          rules that produce a wrong document or an unrecoverable act if they
 *          are missing. Budgeted, measured, and kept under HEAD_BUDGET
 *          (briefing-head.ts, which owns the head's arithmetic).
 *   TAIL — the area tour and everything a client with room should also know.
 *          A client that truncates loses only detail it can rediscover from
 *          the tool list itself.
 */


/**
 * The tail for an account with nothing in it.
 *
 * A new account has no roles, no highlights, no notes and no projects, so an
 * assistant handed a confident description of a populated workspace calls
 * search_me, gets an empty array back and improvises — which is the exact
 * moment this server's one real rule gets broken, because an invented career
 * is the only way left to satisfy the request in front of it.
 */
const EMPTY_WORKSPACE = `This workspace is EMPTY: no roles, no highlights, no notes, no projects.
Every read tool will come back with nothing, and that is the state of the account rather than a
failed call. Say so plainly instead of closing the gap with something plausible.

Ask one thing before anything else: "Do you have a resume or a LinkedIn export you can paste, or
would you rather talk me through your last job?" If they paste a document, parse it yourself and
file the whole thing with ONE import_resume call — roles with their bullets, education, skills,
profile facts — copying what it says and inventing nothing. Pass its create_base_resume argument
in the same call so they see a rendered resume in their first minutes. If they would rather talk
it through, file what comes back with these:
• update_profile — name, contact details, links, and their personal background: what they want
  next, what they will not take.
• create_role — one per job, with dates. The raw material goes in its background, where length is a
  feature: keep the detail rather than summarising it away.
• append_role_background — what they remember about a role after it already exists. It adds;
  update_role replaces, which is why this one is here.
• create_highlights — polished, reusable bullets, once there is raw material to draw them from.
• create_note — whatever belongs to no single job.

Never hand them a form or a list of fields to fill in; that is the thing they came here to stop
doing. Resumes, the application pipeline and the CRM are worth explaining once a role exists, and
not before, because none of them do anything yet.`;

const AREA_HEAD = `The areas:`;

/**
 * The briefing's tail, one entry per area, so a narrowed connection is told
 * about what it was actually served.
 *
 * FULL is the join of every entry. It used to be a second, verbatim copy of
 * the same text, kept so the join could never change what a FULL connection
 * was sent; the two were checked identical and the copy was deleted, because a
 * second copy of a briefing is a briefing that drifts.
 *
 * `needs` names one tool the area is about. An area whose tool is not served on
 * a connection is dropped from its briefing, because telling a client about a
 * tool it cannot see is worse than saying nothing.
 */
const AREA_PARAGRAPHS: { key: string; needs: string; text: string }[] = [
  { key: 'me', needs: 'search_me', text: `• ME — everything about them. Roles each hold an unlimited free-form "background" of raw
  material, plus polished reusable bullets called highlights. A background can carry "Rules"
  (how this job may be described — obey them, do not quote them), "Caveats" (their own
  positioning notes — for you, NEVER for a document) and "Open questions" (facts they have
  not settled — never use one, ask). get_role reads these apart for you, including shouted
  markers like "NAMING RULE:" or "⚠️ OPEN:". Everything else in it is evidence. A role
  with startUnconfirmed or endUnconfirmed has a guessed month: print the year only.
  There are also notes, projects, education, skills and certifications. search_me is the
  fastest way in. get_me_snapshot carries their keyword policy: how close to a posting's own
  words a document may get, and the transfers they have recorded. posting_keywords says where
  one posting's terms land before you write.` },
  { key: 'resumes', needs: 'get_resume_format', text: `• RESUMES — documents assembled from that material. Call get_resume_format before writing one.
  New resumes use the Harvard OCS format by default. Any of them can be published to a public
  link with publish_resume, which is what to use when a form or a recruiter wants a URL.` },
  { key: 'letters', needs: 'prep_letter', text: `• LETTERS — everything they write that is not a resume: cover letters, cold outreach, referral
  asks, thank-yous, replies. prep_letter FIRST, always: it returns the posting, the company
  research, the material in Me that matches, and up to three letters of the same kind they
  wrote themselves, which is the only reliable description of how they sound.` },
  { key: 'pipeline', needs: 'list_applications', text: `• PIPELINE — applications, stages, activity timeline, tasks and follow-up dates. Six stages:
  wishlist, applied, interviewing, offer, accepted and lost. How deep an interview got is
  interviewRound, a number; why something was lost is a LOSS tag. When the question is about a
  stretch of time rather than one application — this week, last month, what is coming — reach for
  list_schedule, which merges all three kinds of dated thing. Application.salaryRange is what the
  POSTING advertised; what somebody actually offered is an Offer row, recorded with record_offer,
  and every call writes a new VERSION rather than replacing the last. offer_briefing before
  helping them answer one; compare_offers puts the live ones side by side and will not convert
  currencies. A stage move can fire a checklist they own (list_stage_templates) and returns what
  it added.` },
  { key: 'review', needs: 'propose_changes', text: `• THE REVIEW QUEUE — propose_changes queues suggestions for them to accept or dismiss on their
  dashboard instead of asking about each one now. That is what to do at the end of a long read
  of their mail: they are rarely at the conversation when you finish. Nothing is written until
  they accept. list_proposals first, so a dismissed suggestion is not offered twice.` },
  { key: 'crm', needs: 'get_company', text: `• CRM — companies and the people at them, as records in their own right. get_company before
  writing anything about a company, so you add to their research rather than replacing it. A
  company's website field is their own domain and nothing else depends on it, but it is what puts
  their logo on the pipeline, so set it whenever you learn it. People have timelines: when they
  mention talking to someone — a call, a coffee, a reply — log_activity with contactId is how it
  gets remembered, and update_contact's nextFollowUpAt is how "ping them in two weeks" actually
  happens. list_follow_ups returns due people alongside due applications.` },
  { key: 'accounts', needs: 'list_correspondence', text: `• MAIL AND CALENDAR — the pipeline's timeline only knows what was logged by hand, so read their
  mail before saying where an application stands. If you have mail or calendar tools of your own
  (a Gmail or Google Calendar connector, say), use those. Otherwise, if they connected an account
  here (list_linked_accounts), list_correspondence returns the real threads and meetings behind
  any contact, company, application or resume, read live and never stored, and search_email and
  search_calendar cover questions that are not about one record. All of it is read-only. With
  neither, say so rather than guessing at their mail.` },
  { key: 'own', needs: 'export_everything', text: `• YOUR OWN DATA — export_everything hands back the whole workspace as one JSON file and
  import_everything puts one back, additively and matched by name, so it never overwrites.
  set_digest_settings turns on the emails this app sends — a Monday summary, a nudge on a day
  something is due, and a monthly ask for one thing that went well — and ALL ARE OFF until
  somebody asks in so many words.
  Never offer to turn one on unprompted; preview_digest answers "what does my week look
  like" without any mail leaving.` },
  { key: 'watching', needs: 'watch_company_board', text: `• WATCHING THE OUTSIDE WORLD — watch_company_board follows one employer's Greenhouse, Lever or
  Ashby board and queues every new matching role as a proposal; the FIRST look proposes nothing
  by design. check_posting_live reports whether the page behind an application is still up, and
  writes only that — it never moves a stage and never logs anything. run_mail_sweep reads the
  mail that arrived from people already on their pipeline and queues what a rule can prove,
  handing everything that needs a judgement back on a needsReading list. All three OFFER; none of
  them writes. set_mail_sweep is off until somebody asks in so many words, like the emails.` },
  { key: 'tags', needs: 'list_tags', text: `• TAGS cut across all of it. Where an application came from, a company's industry, size and
  location, how you know a person, why an application was lost — every one of those is a tag
  rather than a free-text field, and they are multi-select. Call list_tags before writing any of
  them: passing a name that already exists matches it rather than creating a near-duplicate, and
  the kinds are separate lists that never collide.` },
];

/** The tail: the head, then only the areas this connection can reach. */
function areasFor(served: Set<string>, scope: McpScope): string {
  const kept = AREA_PARAGRAPHS.filter((area) => served.has(area.needs)).map((area) => area.text);
  if (scope === "FULL") return [AREA_HEAD, ...kept].join("\n");
  return [
    AREA_HEAD,
    ...kept,
    `\nThis connection is scoped to "${scopeLabel(scope)}" — ${scopeBlurb(scope)} Tools outside it are not served here and are not missing from the account. If they need one, say which, and that they can widen this connection under Settings → Connections.`,
  ].join("\n");
}

/**
 * What follows the areas: the cross-cutting habits. Each names the tools it is
 * about in `needs`, and is sent only when every one of them is served, for the
 * same reason an area is — a narrowed connection told to call a tool it cannot
 * see either fails or improvises.
 */
const AFTER_AREAS: { needs: string[]; text: string }[] = [
  {
    needs: ["list_archive", "restore_records"],
    text: `Deleting a company, a person or an application puts it in the archive rather than destroying it.
list_archive is what is in there and when each thing is due to go; restore_records brings it back.`,
  },
  {
    needs: ["list_connections", "create_connection", "rotate_connection"],
    text: `The connection you are talking through is one of several this person may have — list_connections
shows them all, create_connection wires up another client and hands back its URL and setup steps,
and rotate_connection kills a URL that has leaked.`,
  },
];

const ALSO_WORTH_KNOWING: { needs: string[]; text: string }[] = [
  {
    needs: ["move_application_stage", "tag_records", "schedule_contact_pings", "archive_records"],
    text: `- When the ask covers several records at once, pass them all in one call rather than looping:
  move_application_stage with ids, tag_records, schedule_contact_pings, archive_records.
  tag_records ADDS and REMOVES where update_company and update_contact REPLACE — so "tag these
  nine as fintech" written as nine update_company calls would replace each company's whole industry
  list with fintech alone, losing every other industry they were filed under. Every bulk tool skips
  ids that are not theirs rather than failing the whole call.`,
  },
  {
    needs: ["export_csv"],
    text: `- export_csv turns any of the three lists into a spreadsheet, taking the same filters, search and
  sort as list_companies, list_contacts and list_applications. It is the answer to "send me this
  as a file" — do not assemble one by hand from a list call.`,
  },
  {
    needs: ["tailor_resume_for_application"],
    text: `- For a job on the board, tailor_resume_for_application starts the tailored copy — never edit a
  resume already attached to another application. Its unbacked list is what to rewrite first.`,
  },
  {
    needs: ["publish_resume", "unpublish_resume"],
    text: `- A published resume is readable by anyone holding its link, and unpublish_resume destroys that
  link rather than pausing it. Say which resume you are about to publish, and warn before
  withdrawing a link that may already be out in the world. If it has showPhoto on, that page
  carries their face — mention it before you publish.`,
  },
  {
    needs: ["set_profile_photo"],
    text: `- The profile photo is one picture the whole app shares. set_profile_photo replaces it
  everywhere at once, including on every resume already showing it. Only ever use a file or link
  the user gave you; never find them a picture.`,
  },
];

function servedOnly<T extends { needs: string[]; text: string }>(entries: T[], served: Set<string>) {
  return entries.filter((entry) => entry.needs.every((name) => served.has(name))).map((entry) => entry.text);
}

/**
 * The briefing a client gets on connect.
 *
 * Exported because the built-in assistant is a client: it sends this same text
 * as its system prompt, so there is one description of this app's rules rather
 * than a second one that drifts.
 */
export async function instructionsFor(user: User, scope: McpScope = "FULL") {
  // A failed lookup must not cost someone their briefing, so an unreachable
  // database falls back to the tour rather than telling an established user
  // their workspace is empty.
  const empty = await meIsEmpty(user.id).catch(() => false);

  // The two fixed halves of the head are measured before the rules are asked
  // for, and what is left over is their allowance. briefing-head.ts owns that
  // arithmetic, because the Notes screen and list_notes report the same answer.
  const identity = identityFor(user);
  const allowance = rulesAllowance(user);

  const head = `${identity}${await standingRulesFor(user.id, allowance)}
${CRITICAL_RULES}`;

  if (head.length > HEAD_BUDGET) {
    // Only reachable now if the fixed halves alone exceed the cap, which is a
    // change somebody made to CRITICAL_RULES without measuring. Not fatal — a
    // longer head only means a client that truncates loses the tail sooner —
    // but it is not something to discover from a user report.
    console.warn(
      `[mcp] instructions head is ${head.length} chars, past the ${HEAD_BUDGET} budget: a 2KB client will cut inside it`,
    );
  }

  const served = new Set(toolsFor(user, scope).map((tool) => tool.name));
  const tail = empty ? EMPTY_WORKSPACE : areasFor(served, scope);

  const admin = isAdmin(user)
    ? `\n\nYou are an ${user.role === "SUPER_ADMIN" ? "instance owner" : "admin"}, so the admin_* tools are
also available: inviting people, managing accounts and configuring email. Those act on the
instance, never on another person's career history or resumes. admin_delete_user removes an
account and everything it owns, for good: name who and what goes, and get a plain yes first.`
    : "";

  const after = servedOnly(AFTER_AREAS, served);
  const also = servedOnly(ALSO_WORTH_KNOWING, served);
  return [
    `${head}\n\n${tail}${admin}`,
    ...after,
    ...(also.length ? [`Also worth knowing:\n${also.join("\n")}`] : []),
  ].join("\n\n");
}

type JsonRpcId = string | number | null;

type JsonRpcRequest = {
  jsonrpc: "2.0";
  id?: JsonRpcId;
  method: string;
  params?: Record<string, unknown>;
};

type JsonRpcResponse = {
  jsonrpc: "2.0";
  id: JsonRpcId;
  result?: unknown;
  error?: { code: number; message: string; data?: unknown };
};

const PARSE_ERROR = -32700;
const INVALID_REQUEST = -32600;
const METHOD_NOT_FOUND = -32601;
const INVALID_PARAMS = -32602;
const INTERNAL_ERROR = -32603;

/**
 * The protocol's own error codes, added in 2026-07-28. All three are answered
 * with HTTP 400 rather than 200, which is the part that matters: a client
 * probing for the modern shape reads the status, not the body.
 */
const HEADER_MISMATCH = -32020;
const UNSUPPORTED_PROTOCOL_VERSION = -32022;

function err(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse {
  return { jsonrpc: "2.0", id, error: { code, message, ...(data ? { data } : {}) } };
}

/**
 * A result, shaped for the era that asked for it.
 *
 * The modern revision requires `resultType` on every result and asks servers to
 * name themselves in `_meta`. Both are additive, and a legacy result is not
 * allowed to carry them, so this is the one place that knows the difference —
 * every call site passes the same object it always did.
 */
function ok(id: JsonRpcId, result: object, era: Era): JsonRpcResponse {
  if (era === "legacy") return { jsonrpc: "2.0", id, result };
  return {
    jsonrpc: "2.0",
    id,
    result: {
      resultType: "complete",
      ...result,
      _meta: { [META_SERVER_INFO]: SERVER_INFO },
    },
  };
}

/**
 * A result a client is allowed to cache, which in 2026-07-28 means it MUST say
 * for how long and in what scope.
 *
 * `cacheScope` is always "private": every list on this server is one person's
 * — their tools depend on whether they are an admin, their prompts likewise —
 * so a shared cache keyed on anything but the token would hand one member
 * another's answer. `ttlMs` is zero because the tool list changes on deploy and
 * a member's admin flag changes without one; there is nothing here worth a
 * stale read, and the field is required whether or not it is useful.
 */
function cacheable(id: JsonRpcId, result: object, era: Era): JsonRpcResponse {
  if (era === "legacy") return ok(id, result, era);
  return ok(id, { ...result, ttlMs: 0, cacheScope: "private" }, era);
}

/** Dates need to survive the trip; everything else is plain JSON already. */
/**
 * Compact on purpose. Indentation is for a person reading a log, and the
 * reader here is a model paying for every space: two-space JSON was a quarter
 * of the bytes of a typical list result.
 */
function serialize(value: unknown) {
  return JSON.stringify(value, (_key, val) => (val instanceof Date ? val.toISOString() : val));
}

async function handleMessage(
  message: JsonRpcRequest,
  ctx: McpContext,
  era: Era,
): Promise<JsonRpcResponse | null> {
  const id = message.id ?? null;
  const params = message.params ?? {};
  const isNotification = message.id === undefined || message.id === null;

  switch (message.method) {
    case "initialize": {
      // The legacy list, not the full one: a client sending `initialize` is
      // speaking the old shape whatever version string it names, so answering
      // "2026-07-28" here would promise a result shape this branch never emits.
      const requested = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
      const protocolVersion = LEGACY_PROTOCOL_VERSIONS.includes(requested)
        ? requested
        : LATEST_PROTOCOL_VERSION;
      return ok(id, {
        protocolVersion,
        capabilities: {
          tools: { listChanged: false },
          prompts: { listChanged: false },
        },
        serverInfo: SERVER_INFO,
        instructions: await instructionsFor(ctx.user, ctx.scope),
      }, era);
    }

    /**
     * The modern era's way in, and the reason it needs no handshake: one call
     * that says what versions this server speaks, what it can do, and how to
     * use it. Servers MUST implement it, so it is answered whichever era asked
     * — a legacy client that stumbles onto it gets a usable answer rather than
     * a method-not-found it has no branch for.
     *
     * `supportedVersions` lists 2026-07-28 first. That is a promise: everything
     * above and below this line — resultType, the cache hints, the header
     * checks, the -3202x errors — is what makes the promise true, and the
     * version came off this list until all of it was here.
     */
    case "server/discover":
      return cacheable(
        id,
        {
          supportedVersions: SUPPORTED_PROTOCOL_VERSIONS,
          capabilities: {
            tools: { listChanged: false },
            prompts: { listChanged: false },
          },
          instructions: await instructionsFor(ctx.user, ctx.scope),
        },
        era,
      );

    case "notifications/initialized":
    case "notifications/cancelled":
    case "notifications/progress":
    case "notifications/roots/list_changed":
      return null;

    case "ping":
      return ok(id, {}, era);

    case "tools/list":
      return cacheable(id, {
        tools: toolsFor(ctx.user, ctx.scope).map((tool) => {
          const meta = metaFor(tool.name);
          return {
            name: tool.name,
            title: tool.title,
            description: tool.description,
            inputSchema: tool.inputSchema,
            // Sent on every tool, including where a hint matches the spec's own
            // default: two of the four default to the dangerous answer, and a
            // client cannot tell "we decided this" from "they forgot". The title
            // is repeated inside the block because it lived there before it was
            // promoted to a field of its own, and older clients still read it.
            annotations: { title: tool.title, ...tool.annotations },
            // Vendor hints — which tools stay loaded behind tool search, how
            // much of a result may be read, what must not run unattended. The
            // spec reserves _meta for exactly this, and a client that does not
            // know these keys ignores them.
            ...(meta ? { _meta: meta } : {}),
          };
        }),
      }, era);

    case "tools/call": {
      const name = typeof params.name === "string" ? params.name : "";
      const tool = toolsByName.get(name);
      if (!tool) return err(id, INVALID_PARAMS, `Unknown tool: ${name}`);
      const args = (params.arguments ?? {}) as Record<string, unknown>;
      try {
        // The admin check, the scope check and the change log all live in
        // dispatchTool now, because the in-app assistant calls these same
        // handlers and a check inside this case is a check that caller does not
        // have.
        const dispatched = await dispatchTool(name, args, ctx);
        if (!dispatched.ok) {
          return ok(id, {
            content: [{ type: "text", text: `Error: ${dispatched.message}` }],
            isError: true,
          }, era);
        }
        const result = dispatched.result;
        // Links ride alongside the JSON rather than replacing it: a client that
        // renders resource links gets something clickable, one that doesn't sees
        // exactly what it always saw.
        const { data: noticed, notice } = splitNotice(result);
        const { data, links } = splitLinks(noticed);
        return ok(id, {
          content: [
            // Before the payload, not after: a notice saying a list was cut off
            // is worth nothing if it sits behind the very JSON that gets cut off.
            ...(notice ? [{ type: "text", text: notice }] : []),
            { type: "text", text: serialize(data ?? { ok: true }) },
            ...links,
          ],
        }, era);
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        // The tool name and the failure, never `args` — those are the caller's
        // content and every admin can read the event stream.
        await recordSystemEvent({
          source: "mcp.tool",
          message,
          detail: name,
          userEmail: ctx.user.email,
        });
        // Tool failures are reported in-band so the model can recover.
        return ok(id, { content: [{ type: "text", text: `Error: ${message}` }], isError: true }, era);
      }
    }

    case "prompts/list":
      return cacheable(id, {
        prompts: promptsFor(ctx.user, ctx.scope).map((prompt) => ({
          name: prompt.name,
          title: prompt.title,
          description: prompt.description,
          arguments: prompt.arguments,
        })),
      }, era);

    case "prompts/get": {
      const name = typeof params.name === "string" ? params.name : "";
      const prompt = promptsByName.get(name);
      if (!prompt || (prompt.adminOnly && !isAdmin(ctx.user))) {
        return err(id, INVALID_PARAMS, `Unknown prompt: ${name}`);
      }
      const args = (params.arguments ?? {}) as Record<string, string>;
      return ok(id, {
        description: prompt.description,
        messages: [{ role: "user", content: { type: "text", text: prompt.build(args) } }],
      }, era);
    }

    // Declared as unsupported in `capabilities`, but answer politely rather than
    // erroring so probing clients do not surface a scary message.
    case "resources/list":
      return cacheable(id, { resources: [] }, era);
    case "resources/templates/list":
      return cacheable(id, { resourceTemplates: [] }, era);

    default:
      if (isNotification) return null;
      return err(id, METHOD_NOT_FOUND, `Method not found: ${message.method}`);
  }
}

/**
 * Which browser origins may POST here.
 *
 * The spec makes this a MUST — it is the defence against DNS rebinding, where a
 * page the person is merely visiting reaches an MCP server their browser can
 * see. Almost nothing this touches: every client in the app library connects
 * from a server or a desktop process and sends no Origin at all, and a header
 * that is absent is not "invalid". Only a browser that names a foreign origin
 * is turned away.
 *
 * Loopback is always allowed, on any port, because the MCP Inspector runs there
 * and refusing the standard debugging tool would be its own kind of bug.
 * Anything else an admin needs goes in one comma-separated setting rather than
 * an environment variable, so a self-hoster can fix it without a redeploy.
 */
function originAllowed(origin: string, baseUrl: string, extra: string): boolean {
  let host: URL;
  try {
    host = new URL(origin);
  } catch {
    return false;
  }
  // The hostname off a parsed URL, never a substring of the raw header: a
  // hostname match written as `origin.includes("localhost")` would welcome
  // https://localhost.attacker.example, and one written against the raw string
  // would be fooled by userinfo — http://localhost@attacker.example parses with
  // hostname "attacker.example", which is the answer that matters.
  if (LOOPBACK.has(host.hostname)) return true;
  if (origin === baseUrl) return true;
  return extra
    .split(",")
    .map((entry) => entry.trim().replace(/\/$/, ""))
    .filter(Boolean)
    .includes(origin);
}

/** Every spelling of "this machine" a browser will send. */
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);

export function corsHeaders(): Record<string, string> {
  return {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
    // Mcp-Method and Mcp-Name are required of a 2026-07-28 client, so a browser
    // one cannot connect at all unless the preflight allows them. Mcp-Session-Id
    // and Last-Event-ID belong to a revision that removed both; they stay
    // listed because allowing a header nobody sends costs nothing and removing
    // one an older client still sends would break it.
    "Access-Control-Allow-Headers":
      "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Last-Event-ID",
    "Access-Control-Expose-Headers": "Mcp-Session-Id, MCP-Protocol-Version",
    "Access-Control-Max-Age": "86400",
  };
}

function sseResponse(payload: unknown, status = 200) {
  const body = `event: message\ndata: ${JSON.stringify(payload)}\n\n`;
  const stream = new ReadableStream({
    start(controller) {
      controller.enqueue(new TextEncoder().encode(body));
      controller.close();
    },
  });
  return new Response(stream, {
    status,
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      ...corsHeaders(),
    },
  });
}

function jsonResponse(payload: unknown, status = 200) {
  return new Response(JSON.stringify(payload), {
    status,
    headers: { "Content-Type": "application/json", ...corsHeaders() },
  });
}

/**
 * Which era a single message is speaking.
 *
 * A pure function of one request, which is the whole reason both eras can share
 * a stateless handler: nothing has to remember what was negotiated, because
 * every message says. The header and the body are both consulted because the
 * modern revision requires them to agree — and `checkModernHeaders` below is
 * what rejects the case where they do not.
 */
function eraFor(request: Request, message: JsonRpcRequest): Era {
  if (request.headers.get("mcp-protocol-version") === MODERN_PROTOCOL_VERSION) return "modern";
  const meta = (message.params?._meta ?? {}) as Record<string, unknown>;
  return meta[META_PROTOCOL_VERSION] === MODERN_PROTOCOL_VERSION ? "modern" : "legacy";
}

/**
 * Every protocol version this request names, in either of the two places it can
 * be named. Deduplicated, because the usual case names the same one twice.
 */
function declaredVersions(request: Request, messages: JsonRpcRequest[]): string[] {
  const found = new Set<string>();
  const header = request.headers.get("mcp-protocol-version");
  if (header !== null) found.add(header);
  for (const message of messages) {
    const meta = (message?.params?._meta ?? {}) as Record<string, unknown>;
    const declared = meta[META_PROTOCOL_VERSION];
    if (typeof declared === "string") found.add(declared);
  }
  return [...found];
}

/**
 * The modern revision mirrors three body fields into headers so a proxy can
 * route without parsing, and requires the server to reject any disagreement —
 * the point being that a gateway routing on the header and a server acting on
 * the body must never see two different requests.
 *
 * Notifications are exempt: the revision says their header requirements are
 * undefined, and inventing a rule there would refuse messages nothing is
 * required to shape.
 */
function checkModernHeaders(
  request: Request,
  message: JsonRpcRequest,
): { code: number; message: string } | null {
  const header = (name: string) => request.headers.get(name);
  const params = message.params ?? {};
  const meta = (params._meta ?? {}) as Record<string, unknown>;

  // A missing body field is a params error, not a header mismatch: -32020 is
  // defined for headers that disagree with the body or are absent, and calling
  // an absent body field a header problem sends a client looking at its proxy.
  if (typeof meta[META_PROTOCOL_VERSION] !== "string") {
    return {
      code: INVALID_PARAMS,
      message: `${MODERN_PROTOCOL_VERSION} requires params._meta["${META_PROTOCOL_VERSION}"] on every request.`,
    };
  }
  const declared = header("mcp-protocol-version");
  if (declared !== null && declared !== meta[META_PROTOCOL_VERSION]) {
    return {
      code: HEADER_MISMATCH,
      message:
        `Header mismatch: MCP-Protocol-Version header value '${declared}' ` +
        `does not match body value '${String(meta[META_PROTOCOL_VERSION])}'.`,
    };
  }
  if (meta[META_CLIENT_CAPABILITIES] === undefined) {
    return {
      code: INVALID_PARAMS,
      message:
        `${MODERN_PROTOCOL_VERSION} requires params._meta["${META_CLIENT_CAPABILITIES}"]; ` +
        `send an empty object if the client has no optional capabilities.`,
    };
  }

  const method = header("mcp-method");
  if (method === null) {
    return { code: HEADER_MISMATCH, message: "Header mismatch: Mcp-Method is required and was not sent." };
  }
  if (method !== message.method) {
    return {
      code: HEADER_MISMATCH,
      message: `Header mismatch: Mcp-Method header value '${method}' does not match body value '${message.method}'.`,
    };
  }

  // Mcp-Name mirrors params.name or params.uri, and only for the three methods
  // that carry one. This server serves no resources, so two of the three.
  if (message.method === "tools/call" || message.method === "prompts/get") {
    const expected = typeof params.name === "string" ? params.name : "";
    const sent = header("mcp-name");
    if (sent === null) {
      return {
        code: HEADER_MISMATCH,
        message: "Header mismatch: Mcp-Name is required for this method and was not sent.",
      };
    }
    if (decodeMcpName(sent) !== expected) {
      return {
        code: HEADER_MISMATCH,
        message: `Header mismatch: Mcp-Name header value '${sent}' does not match body value '${expected}'.`,
      };
    }
  }
  return null;
}

/**
 * Undo the sentinel encoding a client uses when a name will not survive as a
 * plain ASCII header value. Every tool and prompt name on this server is
 * lowercase ASCII, so this only ever fires for a client that encodes anyway.
 */
function decodeMcpName(value: string): string {
  const match = /^=\?base64\?(.*)\?=$/.exec(value);
  if (!match) return value;
  try {
    return Buffer.from(match[1], "base64").toString("utf8");
  } catch {
    return value;
  }
}

/** Entry point shared by both MCP routes. */
export async function handleMcpPost(request: Request, caller: McpCaller): Promise<Response> {
  const { user, connectionId, connectionName, scope } = caller;

  const url = new URL(request.url);
  const forwardedHost = request.headers.get("x-forwarded-host");
  const forwardedProto = request.headers.get("x-forwarded-proto");
  const baseUrl = `${forwardedProto ?? url.protocol.replace(":", "")}://${forwardedHost ?? url.host}`;

  // Before anything is parsed or any work is done: a browser naming a foreign
  // origin does not get to act as this person. Recorded rather than silent,
  // because the fix — adding it to the setting — needs the address, and the
  // admin will otherwise only see a client that stopped working.
  const origin = request.headers.get("origin");
  if (origin && !originAllowed(origin, baseUrl, "")) {
    // The settings row is only read once loopback and same-origin have both
    // said no, which is every request a real client makes. An admin's extra
    // origins are the rare case and can afford the query.
    const { mcpAllowedOrigins } = await getSettings().catch(() => ({ mcpAllowedOrigins: "" }));
    if (!originAllowed(origin, baseUrl, mcpAllowedOrigins)) {
      await recordSystemEvent({
        // WARN, not the ERROR that recordSystemEvent defaults to: this is the
        // server working, and instanceHealth counts ERROR rows to decide
        // whether the instance is down — twenty in a day is enough to say so.
        // A browser client that retries would otherwise report a healthy
        // instance as broken, which is the opposite of what this row is for.
        level: "WARN",
        source: "mcp.origin",
        message: `Refused an MCP request from origin ${origin}`,
        detail: "Settings → Admin → Configuration → Instance → Extra MCP origins allows it.",
        userEmail: user.email,
      });
      return jsonResponse(err(null, INVALID_REQUEST, `Origin not allowed: ${origin}`), 403);
    }
  }

  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return jsonResponse(err(null, PARSE_ERROR, "Invalid JSON"), 400);
  }

  const ctx: McpContext = { userId: user.id, user, connectionId, connectionName, scope, baseUrl };

  const wantsSse = (request.headers.get("accept") ?? "").includes("text/event-stream");
  const messages = Array.isArray(body) ? (body as JsonRpcRequest[]) : [body as JsonRpcRequest];

  if (messages.length === 0) {
    return jsonResponse(err(null, INVALID_REQUEST, "Empty batch"), 400);
  }

  // A version this server does not speak is refused before the message is even
  // looked at, and the refusal names what it does speak so the client can pick
  // one and retry. HTTP 400 rather than 200 is the load-bearing part: it is the
  // status a modern client reads to decide whether to fall back.
  //
  // Both places it can be declared are checked. Reading only the header let a
  // client naming an unknown version in `params._meta` — with no header at all,
  // which is a shape the modern revision permits — fall through and be served a
  // legacy answer, which is the silent wrong-version failure this check exists
  // to prevent.
  for (const declared of declaredVersions(request, messages)) {
    if (!SUPPORTED_PROTOCOL_VERSIONS.includes(declared)) {
      return jsonResponse(
        err(
          messages[0]?.id ?? null,
          UNSUPPORTED_PROTOCOL_VERSION,
          `Unsupported protocol version: ${declared}`,
          { requested: declared, supported: SUPPORTED_PROTOCOL_VERSIONS },
        ),
        400,
      );
    }
  }

  // Batching was legal in exactly one revision, 2025-03-26, which this server
  // still answers — so an array is still accepted rather than refused. The
  // modern revision requires a single message, and says so here rather than
  // quietly answering half of one.
  if (Array.isArray(body) && messages.some((message) => message && eraFor(request, message) === "modern")) {
    return jsonResponse(
      err(null, INVALID_REQUEST, `${MODERN_PROTOCOL_VERSION} requires one JSON-RPC message per POST, not an array.`),
      400,
    );
  }

  const responses: JsonRpcResponse[] = [];
  let modernRequested = false;
  for (const message of messages) {
    if (!message || typeof message !== "object" || typeof message.method !== "string") {
      responses.push(err(null, INVALID_REQUEST, "Invalid JSON-RPC message"));
      continue;
    }
    const era = eraFor(request, message);
    if (era === "modern") modernRequested = true;
    // A notification is a message with NO id. `id: null` is a request with a
    // null id, and dispatch treats it as a notification for legacy reasons —
    // which meant a modern tools/call could carry `id: null` and skip every
    // mirrored-header check while still running the tool. The exemption here
    // is the narrow one the revision actually grants.
    if (era === "modern" && "id" in message) {
      const problem = checkModernHeaders(request, message);
      if (problem) return jsonResponse(err(message.id ?? null, problem.code, problem.message), 400);
    }
    try {
      const response = await handleMessage(message, ctx, era);
      if (response) responses.push(response);
    } catch (error) {
      const detail = error instanceof Error ? error.message : String(error);
      responses.push(err(message.id ?? null, INTERNAL_ERROR, detail));
    }
  }

  // Everything was a notification — the spec wants 202 with no body.
  if (responses.length === 0) {
    return new Response(null, { status: 202, headers: corsHeaders() });
  }

  const payload = Array.isArray(body) ? responses : responses[0];

  // A method this server does not implement is a 404 in the modern era and a
  // 200 carrying a JSON-RPC error in the old one, and the difference is not
  // pedantry: the status is how a modern client tells "this endpoint does not
  // host that method" from "this is not an MCP endpoint at all". Only ever
  // applied to a single unbatched message, since a batch has no one status.
  //
  // Decided before the content type is chosen rather than after, because the
  // status is a fact about the request and asking for SSE does not change it.
  // It used to sit below the SSE return, so a modern client that advertised
  // text/event-stream — which is every client the spec describes, since one
  // MUST accept both — got a 200 for a method that does not exist.
  const single = Array.isArray(body) ? null : responses[0];
  const status = single?.error?.code === METHOD_NOT_FOUND && modernRequested ? 404 : 200;

  return wantsSse ? sseResponse(payload, status) : jsonResponse(payload, status);
}

export function mcpUnauthorized() {
  // Deliberately no WWW-Authenticate header: this server uses a token embedded
  // in the URL, and advertising a challenge would send clients down an OAuth
  // discovery path that does not exist here.
  return jsonResponse(
    {
      error: "unauthorized",
      message:
        "Missing, invalid or suspended token. Copy your personal connection URL from the Settings page of your Hired.",
    },
    401,
  );
}