@stackscan/mcp-server
Advanced tools
+115
-3
@@ -18,3 +18,7 @@ #!/usr/bin/env node | ||
| import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; | ||
| import { createRequire } from "node:module"; | ||
| import { z } from "zod"; | ||
| // The version from package.json, so the MCP handshake can never drift from | ||
| // the published version again (0.1.1 shipped introducing itself as 0.1.0). | ||
| const PKG_VERSION = createRequire(import.meta.url)("../package.json").version; | ||
| const API_BASE = (process.env.STACKSCAN_API_BASE ?? "https://api.stackscan.com").replace(/\/+$/, ""); | ||
@@ -184,3 +188,3 @@ /** | ||
| } | ||
| const server = new McpServer({ name: "stackscan", version: "0.1.0" }); | ||
| const server = new McpServer({ name: "stackscan", version: PKG_VERSION }); | ||
| server.registerTool("check_credits", { | ||
@@ -240,3 +244,3 @@ description: "Check the StackScan credit balance. Free - does not consume a credit. " + | ||
| domain: z.string().describe("Bare domain, e.g. example.com (no scheme, no path)"), | ||
| limit: z.number().int().min(1).max(50).optional().describe("Max technologies to return (default 25, cap 50)"), | ||
| limit: z.number().int().min(1).max(100).optional().describe("Max technologies to return (default 50, cap 100). 50 is the full stack for over 99.9% of domains."), | ||
| }, | ||
@@ -247,3 +251,3 @@ }, async ({ domain, limit }) => { | ||
| return blocked; | ||
| const result = await apiGet("domains/lookup", { domain, per_page: limit ?? 25 }); | ||
| const result = await apiGet("domains/lookup", { domain, per_page: limit ?? 50 }); | ||
| if (!result.ok) | ||
@@ -390,2 +394,110 @@ return text(result.noData ? noDataNote(`the domain ${domain}`) : result.message); | ||
| }); | ||
| /** | ||
| * Technologies per domain in a batch result. | ||
| * | ||
| * Lower than the REST endpoint's default of 10 for the same reason BATCH_MAX | ||
| * is lower than 100: this lands in the model's context. Twenty domains at ten | ||
| * technologies each is two hundred rows, which crowds out the conversation. | ||
| * Six covers the recognisable stack of a typical site (the median domain has | ||
| * three technologies overall) and the count of what was left out is always | ||
| * reported, so the model can offer to drill in with lookup_domain_technologies. | ||
| */ | ||
| const BATCH_PER_DOMAIN = 6; | ||
| server.registerTool("lookup_domains_technologies", { | ||
| description: `Look up the technologies on up to ${BATCH_MAX} domains in ONE call, returned as a compact table. ` + | ||
| "Prefer this over repeated lookup_domain_technologies calls whenever you have several domains in hand - " + | ||
| "it is one request instead of many and costs the same per resolved domain. Answers questions like " + | ||
| "'which of these run Shopify?'. Pass `category` to narrow to one kind of technology. " + | ||
| "For the full detail on ONE domain (every technology with its category and global usage), use " + | ||
| "lookup_domain_technologies instead. Duplicates and www. variants collapse and are charged once. " + | ||
| "Costs 1 credit per domain that HAS data; misses and malformed domains are free.", | ||
| inputSchema: { | ||
| domains: z | ||
| .array(z.string()) | ||
| .min(1) | ||
| .max(BATCH_MAX) | ||
| .describe(`Bare domains, e.g. ["example.com","stripe.com"]. Maximum ${BATCH_MAX}.`), | ||
| category: z | ||
| .string() | ||
| .optional() | ||
| .describe('Only return technologies in this category, e.g. "Ecommerce" or "Hosting & Infrastructure". ' + | ||
| "Case-insensitive. Omit for all categories."), | ||
| per_domain: z | ||
| .number() | ||
| .int() | ||
| .min(1) | ||
| .max(25) | ||
| .optional() | ||
| .describe(`Technologies to show per domain (default ${BATCH_PER_DOMAIN}, max 25).`), | ||
| }, | ||
| }, async ({ domains, category, per_domain }) => { | ||
| // Collapse before checking affordability, so the budget is measured | ||
| // against what will actually be charged rather than what was typed. | ||
| const unique = [...new Set(domains.map((d) => d.trim().toLowerCase()).filter((d) => d !== ""))]; | ||
| if (unique.length === 0) { | ||
| return text("No usable domains were given."); | ||
| } | ||
| const remaining = SESSION_LOOKUP_CAP - lookupsThisSession; | ||
| if (remaining <= 0) { | ||
| return refuseIfCapReached(); | ||
| } | ||
| // Refuse rather than silently truncate, same as lookup_companies. | ||
| if (unique.length > remaining) { | ||
| return text(`That would cost up to ${unique.length} credits, but only ${remaining} of this session's ` + | ||
| `${SESSION_LOOKUP_CAP}-lookup cap remain. Nothing was charged. Ask for ${remaining} domains or fewer, ` + | ||
| `or tell the user they can raise STACKSCAN_SESSION_LOOKUP_CAP.`); | ||
| } | ||
| const perDomain = per_domain ?? BATCH_PER_DOMAIN; | ||
| const body = { domains: unique, per_domain: perDomain }; | ||
| if (category && category.trim() !== "") { | ||
| body.category = category.trim(); | ||
| } | ||
| const result = await apiPost("domains/batch", body); | ||
| if (!result.ok) | ||
| return text(result.message); | ||
| const d = result.data; | ||
| // Trust the server's own figure rather than counting rows: it is what was | ||
| // actually billed, and it already accounts for collapsed duplicates. | ||
| recordSpend(d.credits_charged); | ||
| const hits = d.results.filter((r) => r.success && (r.technologies?.length ?? 0) > 0); | ||
| // Clip two short of the column width so a long value cannot run into the | ||
| // next column - see the same helper in lookup_companies. | ||
| const cell = (v, width) => { | ||
| const s = (v ?? "-").trim() || "-"; | ||
| const max = width - 2; | ||
| return (s.length > max ? s.slice(0, max - 1) + "…" : s).padEnd(width); | ||
| }; | ||
| const scope = d.category ? ` in "${d.category}"` : ""; | ||
| const lines = [ | ||
| `${d.requested} domains requested, ${hits.length} with technologies${scope} (${d.credits_charged} credits).`, | ||
| "", | ||
| ]; | ||
| // Names joined per domain rather than a row per technology: this is the | ||
| // breadth view. Depth (category and global usage per technology) is what | ||
| // lookup_domain_technologies is for, and the description says so. | ||
| for (const r of hits) { | ||
| const names = (r.technologies ?? []).map((t) => t.technology ?? t.name ?? "?"); | ||
| const total = r.total_technologies ?? names.length; | ||
| const hidden = total - names.length; | ||
| const more = hidden > 0 ? ` (+${hidden} more)` : ""; | ||
| lines.push(` ${cell(r.domain, 26)}${names.join(", ")}${more}`); | ||
| } | ||
| if (d.not_found.length > 0) { | ||
| const label = d.category ? `No technologies${scope} (not charged)` : "No data (not charged)"; | ||
| lines.push("", ` ${label}: ${d.not_found.join(", ")}`); | ||
| } | ||
| if (d.invalid.length > 0) { | ||
| lines.push("", ` Not valid domains (not charged): ${d.invalid.join(", ")}`); | ||
| } | ||
| // The account ran dry mid-request. Distinct from "no data": these are worth | ||
| // retrying after a top-up, and the model should say so rather than report | ||
| // them as having nothing. | ||
| if (d.skipped_insufficient_credits.length > 0) { | ||
| lines.push("", ` NOT looked up - the account ran out of credits: ${d.skipped_insufficient_credits.join(", ")}`, " These still have data. Retry them after topping up."); | ||
| } | ||
| const spent = SESSION_LOOKUP_CAP - lookupsThisSession; | ||
| const balance = lastKnownBalance === null ? "" : ` Account balance was ${lastKnownBalance} credits at last check.`; | ||
| lines.push("", `(Used ${d.credits_charged} credits. ${spent} of this session's ${SESSION_LOOKUP_CAP} lookups remaining.${balance})`); | ||
| return text(lines.join("\n")); | ||
| }); | ||
| async function main() { | ||
@@ -392,0 +504,0 @@ const transport = new StdioServerTransport(); |
+1
-1
| { | ||
| "name": "@stackscan/mcp-server", | ||
| "version": "0.1.1", | ||
| "version": "0.2.0", | ||
| "description": "MCP server for the StackScan Tech Lookup API - look up the technology stack and the company behind any domain.", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+15
-5
@@ -67,12 +67,22 @@ # StackScan MCP server | ||
| | `lookup_company` | The company behind a domain: name, industry, city, country, LinkedIn | 1 credit | | ||
| | `lookup_domain_technologies` | Technologies detected on a domain, with categories (`limit`, max 50) | 1 credit | | ||
| | `lookup_domain_technologies` | Technologies on ONE domain, in full: category and global usage for each (`limit`, default 50, max 100) | 1 credit | | ||
| | `lookup_technology` | How many sites run a technology, and where they are | 1 credit | | ||
| | `lookup_companies` | Up to 20 domains in one call, returned as a compact table | 1 credit per domain with data | | ||
| | `lookup_companies` | The companies behind up to 20 domains, as a compact table | 1 credit per domain with data | | ||
| | `lookup_domains_technologies` | The technologies on up to 20 domains, as a compact table. Optional `category` filter | 1 credit per domain with data | | ||
| A lookup that finds nothing is **not** charged. Neither is `check_credits`. | ||
| ### Why `lookup_companies` stops at 20 | ||
| ### Breadth vs depth | ||
| The REST endpoint behind it takes 100 domains per request, and this tool | ||
| deliberately does not. A tool result goes straight into the model's context, and a | ||
| There are two technology tools and they answer different questions. | ||
| `lookup_domains_technologies` is the **breadth** view: many domains, technology | ||
| names only, ideal for "which of these run Shopify?". `lookup_domain_technologies` | ||
| is the **depth** view: one domain, every technology with its category and how many | ||
| sites use it globally. The batch tool reports how many technologies it left out per | ||
| domain, so the model can offer to drill in. | ||
| ### Why the batch tools stop at 20 | ||
| The REST endpoints behind them take 100 domains per request, and these tools | ||
| deliberately do not. A tool result goes straight into the model's context, and a | ||
| hundred full company payloads is tens of thousands of tokens, which crowds out the | ||
@@ -79,0 +89,0 @@ conversation you are actually having, and the model then has to re-read all of it |
Debug access
Supply chain riskUses debug, reflection and dynamic code execution features.
33999
25.23%506
28.43%179
5.92%6
20%