← back to Govauctions Mcp

govauctions-mcp.mjs

153 lines

#!/usr/bin/env node
// govauctions-mcp — MCP server exposing live US government auction listings via
// the free official GSA Auctions API (https://gsa.github.io/auctions_api/).
//
// Tools: search_auctions, get_auction, auction_summary.
// The GSA endpoint returns ALL active auctions in one JSON payload, so we fetch
// once and cache ~5 min, then filter in-memory — near-zero API calls.
//
// Env: GSA_API_KEY (defaults to public DEMO_KEY). GOVAUCTIONS_API_KEY is accepted
// as an alias, but GSA is free and DEMO_KEY works out of the box.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const GSA_URL = "https://api.gsa.gov/assets/gsaauctions/v2/auctions";
const API_KEY = process.env.GSA_API_KEY || process.env.GOVAUCTIONS_API_KEY || "DEMO_KEY";
const CACHE_MS = 5 * 60 * 1000;

let cache = { at: 0, rows: [] };

const num = (v) => {
  const n = Number(String(v ?? "").replace(/[$,]/g, ""));
  return Number.isFinite(n) ? n : 0;
};

function normalize(r) {
  const sale = (r.saleNo || "").trim();
  const lot = (r.lotNo || "").trim();
  return {
    id: [sale, lot].filter(Boolean).join("-"),
    saleNo: sale,
    lotNo: lot,
    title: (r.itemName || "").trim(),
    description: r.lotInfo || "",
    status: r.auctionStatus || "",
    startDate: r.aucStartDt || null,
    endDate: r.aucEndDt || null,
    currentBid: num(r.highBidAmount),
    reserve: num(r.reserve),
    bidders: num(r.biddersCount),
    city: r.propertyCity || "",
    state: r.propertyState || "",
    zip: r.propertyZip || "",
    agency: r.agencyName || "",
    bureau: r.bureauName || "",
    url: r.itemDescURL || "https://gsaauctions.gov",
    imageUrl: r.imageURL || null,
  };
}

async function getFeed() {
  const now = Date.now();
  if (cache.rows.length && now - cache.at < CACHE_MS) return cache.rows;
  const res = await fetch(GSA_URL, { headers: { "X-API-KEY": API_KEY, Accept: "application/json" } });
  if (!res.ok) throw new Error(`GSA Auctions API ${res.status}: ${(await res.text()).slice(0, 160)}`);
  const data = await res.json();
  const rows = (data.Results || []).map(normalize).filter((r) => r.title);
  cache = { at: now, rows };
  return rows;
}

function ok(obj) {
  return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
}
function fail(msg) {
  return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
}

const server = new McpServer({ name: "govauctions", version: "0.1.0" });

server.registerTool(
  "search_auctions",
  {
    title: "Search government auctions",
    description:
      "Search live active US federal-surplus auctions (GSA Auctions API). Filter by keyword (matches item name/description), 2-letter state, and/or agency. Returns normalized listings.",
    inputSchema: {
      keyword: z.string().optional().describe("term to match in item name/description, e.g. 'forklift'"),
      state: z.string().optional().describe("2-letter state code, e.g. 'CA'"),
      agency: z.string().optional().describe("agency name fragment"),
      openOnly: z.boolean().optional().describe("only auctions currently accepting bids"),
      limit: z.number().int().min(1).max(200).optional(),
    },
  },
  async ({ keyword, state, agency, openOnly, limit = 25 }) => {
    try {
      let rows = await getFeed();
      if (keyword) {
        const k = keyword.toLowerCase();
        rows = rows.filter((r) => `${r.title} ${r.description}`.toLowerCase().includes(k));
      }
      if (state) rows = rows.filter((r) => r.state.toUpperCase() === state.toUpperCase());
      if (agency) rows = rows.filter((r) => r.agency.toLowerCase().includes(agency.toLowerCase()));
      if (openOnly) rows = rows.filter((r) => /active|open|bidding/i.test(r.status));
      return ok({ count: rows.length, results: rows.slice(0, limit) });
    } catch (e) {
      return fail(e.message);
    }
  },
);

server.registerTool(
  "get_auction",
  {
    title: "Get a specific auction",
    description: "Fetch a single auction lot by sale number (and optional lot number).",
    inputSchema: {
      saleNo: z.string().describe("GSA sale number, e.g. '4-1-QSC-I-26-385'"),
      lotNo: z.string().optional(),
    },
  },
  async ({ saleNo, lotNo }) => {
    try {
      const rows = await getFeed();
      const matches = rows.filter((r) => r.saleNo === saleNo && (!lotNo || r.lotNo === lotNo));
      if (!matches.length) return fail(`No auction found for sale ${saleNo}${lotNo ? ` lot ${lotNo}` : ""}`);
      return ok(matches.length === 1 ? matches[0] : { count: matches.length, lots: matches });
    } catch (e) {
      return fail(e.message);
    }
  },
);

server.registerTool(
  "auction_summary",
  {
    title: "Summary of active auctions",
    description: "Totals for currently-listed federal auctions: overall count, breakdown by state and by status.",
    inputSchema: {},
  },
  async () => {
    try {
      const rows = await getFeed();
      const by = (key) =>
        Object.fromEntries(
          Object.entries(
            rows.reduce((m, r) => ((m[r[key] || "?"] = (m[r[key] || "?"] || 0) + 1), m), {}),
          )
            .sort((a, b) => b[1] - a[1])
            .slice(0, 12),
        );
      return ok({ total: rows.length, byState: by("state"), byStatus: by("status") });
    } catch (e) {
      return fail(e.message);
    }
  },
);

const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`govauctions MCP ready (GSA Auctions API, key ${API_KEY === "DEMO_KEY" ? "DEMO_KEY" : "custom"})`);