Aa-ronJS

Doorknock

Community Aa-ronJS
Updated

Remote MCP server: research a company from its own domain, qualify it against rules you supply, and write the result to HubSpot. Every fact carries its source.

Doorknock

A remote MCP server that researches a company from its own domain, judges itagainst a lead profile you supply, and writes the result into HubSpot.

Everything it reports was read live from the company's own website or its publicDNS records, and carries the URL or DNS query that produced it. It buys nothingfrom a data provider and infers nothing about revenue, headcount or ownership.

Live: https://doorknock-eight.vercel.appMCP endpoint: https://doorknock-eight.vercel.app/mcpOpenAPI: https://doorknock-eight.vercel.app/openapi.jsonHealth: https://doorknock-eight.vercel.app/health

Connect it

claude mcp add --transport http doorknock https://doorknock-eight.vercel.app/mcp

Any MCP client that speaks Streamable HTTP can use the same URL. It is POSTonly: the server is stateless, so GET and DELETE answer 405 by design.

The research tools need no credentials at all. The HubSpot tools need a privateapp token with crm.objects.companies.read and .write, supplied as a header:

claude mcp add --transport http doorknock https://doorknock-eight.vercel.app/mcp \
  --header "X-HubSpot-Token: pat-na1-..."

For anything that does not speak MCP, the identical operations are at/v1/<operation> and described by /openapi.json, which is generated from thesame constants the router uses so the two cannot drift. That is what a customGPT Action or an n8n HTTP node should point at.

The seven tools

Tool What it does
research_company Reads the home page and the DNS. Returns the marketing and CRM tools loaded on the page, which standard pages exist, who handles their email, and whether their outbound email is protected.
qualify_lead Runs the research, then scores it against a profile you pass in, returning a tier with the reasoning attached to every rule.
check_email_posture MX, SPF and DMARC, read in plain words. Keeps "will my mail reach them" and "does their mail land" apart, because they are different questions.
list_signals The vocabulary that profile rules are written against: every named fact, what it means, and what its absence does and does not prove.
hubspot_check_connection Confirms the token works and reports the portal and scopes, before a write fails at the worst moment.
hubspot_find_company Finds a company by exact domain, so an enrichment does not create a duplicate.
hubspot_write_company Creates or updates the company and optionally adds a timeline note.

Three decisions worth arguing about

The credential never touches the model. The obvious design is a tokenparameter on the HubSpot tools. That puts the secret in the prompt, in thecontext window, in the client's transcript and in any log that records toolcalls, and it makes the token something a model can be talked into sendingsomewhere else. Here it travels on the HTTP request instead, in anX-HubSpot-Token header, and no tool schema has a field for it. The model canask for a write; it never handles the credential. One of the adversarial checksexists purely to assert that no tool schema has ever grown a credential field.

The rules are data, not code. Qualification rules arrive with the call andcome back with the answer, so the logic that produced a verdict is alwaysvisible and changing who counts as a good lead never needs a deployment. A rulenaming a signal that does not exist is reported as an unknown signal rather thansilently counted as a miss, and a rule whose signal could not be determinedblocks the tier instead of failing it, because "we could not tell" and "no" senda lead to different places.

Absence is reported as absence. A tag can load through a tag manager, sitbehind a consent banner, or live only on an inner page. So the answer says "notdetected on the home page", never "they do not use HubSpot", and every signalcarries a sentence about what its absence does not prove.

Run it locally

npm install
npm run typecheck     # tsc, no emit
npm run smoke         # every operation against the real internet
npm run smoke:mcp     # every endpoint over a real node:http server
npm run smoke:live -- https://doorknock-eight.vercel.app    # the deployment, via the official client SDK
npm run adversarial -- https://doorknock-eight.vercel.app   # tries to break it
npm run prove -- https://doorknock-eight.vercel.app         # independent evidence

There are no mocks anywhere. A mocked resolver proves that my mock returns whatI told it to; the failures worth catching are an upstream changing shape, and nomock has ever caught one.

npm run adversarial is the one that finds things. It aims at the cloudmetadata endpoint, throws hostile hostnames, wrong types, 4000-characterdomains and malformed protocol frames at the deployed server, and treats aconfident answer to a question the server should have refused as the worst kindof failure. It found three real defects on its first run.

Checking it without trusting this repository

npm run prove exists because my own tests passing proves very little tosomeone who did not write them. It calls the deployed server through theofficial MCP client SDK, then re-fetches every DNS fact from a differentresolver than the server used and re-fetches the page straight from the company,comparing field by field and printing every source URL.

Three more ways that need nothing from here:

# Anthropic's own inspector, not my code
npx @modelcontextprotocol/inspector --cli https://doorknock-eight.vercel.app/mcp --transport http --method tools/list

# raw JSON-RPC, no client, no session, because it is stateless
curl -s -X POST https://doorknock-eight.vercel.app/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# let your own client run its health check against it
claude mcp add --transport http doorknock https://doorknock-eight.vercel.app/mcp
claude mcp list

And the version that needs only a browser. Open both and compare:

What broke, and what I did about it

Six defects so far. Three came from the adversarial script on its first runagainst the deployment, one from the independent-verification script, and onewas in a test rather than in the server.

1. A refusal wrapped in a success. Asking the server about169.254.169.254 or metadata.google.internal returned HTTP 200 with a resultobject. Nothing leaked: the fetch guard refused the request correctly, so nointernal address was ever contacted. But the research call runs the site fetchand the DNS lookups in parallel, and the DNS half carried on happily, so thecaller got a 200 wrapped around a refusal. Defence in depth had worked and theresponse contract had not. The address rules now run in normaliseDomain, atthe front door, so the whole operation refuses with 400.

2. Redirects were followed by the runtime, which checked nothing. The firstversion passed redirect: 'follow' and validated only the first hop. A publichost answering 302 to an internal address would have sailed straight through theguard. Redirects are now followed by hand, five maximum, with every hopre-resolved and re-checked before it is taken, and the chain returned in theanswer because where a domain sends you is itself a finding.

3. A 4000-character domain was accepted. No length check anywhere. DNSitself stops at 253 characters and 63 per label, so anything longer cannot be areal name. Now refused with an explanation.

4. Equal-priority MX records came back in a different order every call.Found by the independent-verification script, which reported a differencebetween what the server said and what a second resolver said. The records wereidentical; only the order differed, because resolvers rotate equal-priorityanswers and the sort was by priority alone, which is not a total order. Ties arenow broken by host name, so the same records always produce the same output.

5. A test that failed on something it never looked at. The adversarialscript truncated every response body to 600 characters before checking it, sothe unknown-signal assertion looked for a field about four kilobytes into theresponse, never saw it, and reported the server broken when the server wascorrect. That cost more time than any of the real defects. The body is now keptwhole and truncated only when it is printed. A test that decides a pass or afail from something it never actually read is worse than no test, because it isbelieved.

6. The platform hands the handler a Node IncomingMessage, not a WebRequest. Inherited from the previous server I built this way, and the reasonnpm run smoke:mcp stands up a real node:http server and talks to it over asocket rather than building request objects in memory. A test that builds itsown request shape only ever tests that shape.

Limits, stated plainly

  • It reads the home page only. A tag loaded through a tag manager or livingon an inner page is missed, and the answer says so rather than implying thetool is absent.
  • It knows nothing about headcount, revenue, ownership or contact names, andwill not guess a domain from a company name.
  • DKIM is not checked. The selector cannot be discovered from DNS, so checkingit means guessing, and a guess that misses looks identical to a domain with noDKIM at all.
  • The address guard resolves the name, checks the addresses, then fetches byname, so a DNS record changing between those two steps is not fully closed.Pinning the connection to the checked address needs an agent this runtime doesnot expose. The mitigation is that no response body ever reaches the callerraw, only named matched signals, so a successful rebind returns nothingreadable to whoever attempted it. This is written in the code as well as here.

How it is laid out

src/core.ts            the operations, shared by both front doors
src/mcpServer.ts       the MCP tool surface
src/lib/guard.ts       what this server is allowed to fetch
src/lib/dns.ts         DNS over HTTPS, so every fact has a URL
src/lib/site.ts        redirect-by-hand page fetching and reading
src/lib/fingerprints.ts  the technology table, one literal string per entry
src/lib/email.ts       MX, SPF and DMARC, read in plain words
src/lib/signals.ts     the published vocabulary
src/lib/qualify.ts     the rules engine
src/lib/hubspot.ts     the CRM writes, with the caller's own token
api/                   the four endpoints
scripts/               five suites, no mocks

MIT licensed.

MCP Server ยท Populars

MCP Server ยท New

    frankchu91

    MindBase โ€” Karpathy's LLM Wiki, as a product

    Karpathy's LLM Wiki idea as a product โ€” an AI that builds and maintains a markdown wiki from your notes and sources. MCP server + web UI, runs on free local models (Ollama), no API key needed. MIT.

    Community frankchu91
    aakarim

    ๐Ÿ“œ OpenLore

    A minimal, extensible, agent-native knowledge base that keeps shared context current and inspectable

    Community aakarim
    sv-grid

    @svgrid/mcp

    Native Svelte 5 data grid. Headless-first engine + drop-in render component. Row + column virtualization (1M rows), Excel-style filters, inline editing, grouping, pivot, server-side data. MIT core (@svgrid/grid), MCP server for Claude / Cursor. https://svgrid.com

    Community sv-grid
    cinderline

    NorthCinder

    Buyer-run, ad-neutral shopping-agent MCP software with deterministic ranking, signed purchase mandates, and a local audit trail.

    Community cinderline
    amanhij

    Zikkaron

    Biologically-inspired persistent memory engine for Claude Code. 26 cognitive subsystems, Hopfield networks, predictive coding, causal discovery, successor representations, all running locally over SQLite.

    Community amanhij