Sign In

@stackscan/mcp-server

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@stackscan/mcp-server - npm Package Compare versions

Comparing version
0.1.1
to
0.2.0
+115
-3
build/index.js

@@ -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": [

@@ -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