---
title: "ThemeSniffer Developer Portal: API, MCP Server and WebMCP Tools"
description: "Free JSON API to detect WordPress, themes, plugins, hosting, CDN, fonts and colors on any website. No API key. Includes a live sandbox, an OpenAPI spec, a remote MCP server with MCP Apps views, and in-page WebMCP tools."
canonical_url: "https://themesniffer.com/developers"
published: "2026-09-08"
author: "Ruslan Saifullin"
source: "Generated from HTML by scripts/build-markdown.mjs — do not edit by hand."
---

Developer Portal

# Build on ThemeSniffer

Every analysis this site runs is a public HTTP endpoint, an MCP tool, and an in-page WebMCP tool. No API key, no account, no SDK to install.

## What you can call

ThemeSniffer analyzes any public website from the outside and answers four questions: does it run WordPress, what is it built with, what fonts does it use, and what colors. The website, the API, the MCP server, and the WebMCP tools all run the same detection code, so all four give the same answer.

On this page

-   [Quickstart](https://themesniffer.com/developers#quickstart)
-   [Authentication and rate limits](https://themesniffer.com/developers#auth)
-   [Endpoint reference](https://themesniffer.com/developers#endpoints)
-   [Sandbox](https://themesniffer.com/developers#sandbox)
-   [Errors](https://themesniffer.com/developers#errors)
-   [MCP server](https://themesniffer.com/developers#mcp)
-   [WebMCP](https://themesniffer.com/developers#webmcp)
-   [Machine-readable discovery](https://themesniffer.com/developers#discovery)
-   [What detection cannot see](https://themesniffer.com/developers#limits)
-   [Support](https://themesniffer.com/developers#support)

| Surface | Address | Use it when |
| --- | --- | --- |
| HTTP JSON API | `https://themesniffer.com/api/…` | You are writing code: a script, a backend job, a spreadsheet import. |
| MCP server | `https://themesniffer.com/api/mcp` | You want an AI assistant to run these analyses as tools, with interactive views rendered in the conversation. |
| WebMCP tools | Registered on every tool page | A browser agent is already on the page and should call a tool instead of filling in the form. |
| OpenAPI 3.1 spec | `[https://themesniffer.com/openapi.json](https://themesniffer.com/openapi.json)` | You want generated clients, or an agent needs the machine-readable contract. |

## Quickstart

### One request, no setup

Ask whether a site runs WordPress. This is the whole integration:

```
curl "https://themesniffer.com/api/check-wordpress?url=wordpress.org"
```

The response:

```
{
  "success": true,
  "isWordPress": true,
  "confidence": "high",
  "signals": {
    "metaGenerator": "WordPress 6.7.1",
    "wpContent": true,
    "wpIncludes": true,
    "wpBlocks": true,
    "restApi": true,
    "xPoweredBy": false,
    "linkHeader": true,
    "xPingback": false
  },
  "signalCount": 6,
  "totalSignals": 8,
  "poweredBy": "PHP/8.2.15",
  "theme": {
    "name": "Twenty Twenty-Four",
    "slug": "twentytwentyfour",
    "nameSource": "style.css",
    "inRepo": true,
    "wpOrgUrl": "https://wordpress.org/themes/twentytwentyfour/",
    "version": "1.3",
    "versionSource": "style.css",
    "latestVersion": "1.6",
    "outdated": true,
    "author": "the WordPress team",
    "activeInstalls": 500000
  },
  "url": "https://wordpress.org/",
  "checkedAt": "2026-09-08T10:15:00.000Z"
}
```

### JavaScript

```
const res = await fetch(
  'https://themesniffer.com/api/tech-stack?url=' + encodeURIComponent('example.com')
);
const data = await res.json();

if (!data.success) throw new Error(data.error);

console.log(data.theme?.name, data.plugins.map(p => p.name));
```

CORS is open (`Access-Control-Allow-Origin: *`) on every endpoint, so this works from a browser as well as from a server.

### Python

```
import requests

r = requests.get(
    "https://themesniffer.com/api/tech-stack",
    params={"url": "example.com"},
    headers={"User-Agent": "my-research-script/1.0 (contact@example.com)"},
    timeout=60,
)
data = r.json()

if not data.get("success"):
    raise RuntimeError(data["error"])

print(data["theme"], data["hosting"], data["performance"]["score"])
```

### Bulk analysis

Analyzing a list of domains is a loop with a delay. Stay inside the published rate limits, send a descriptive `User-Agent` so a problem can be traced back to you, and cache results: sites do not change their theme between two requests a second apart.

```
for domain in $(cat domains.txt); do
  curl -sS --max-time 60 \
    -H "User-Agent: my-bulk-audit/1.0 (contact@example.com)" \
    "https://themesniffer.com/api/tech-stack?url=$domain" >> results.jsonl
  sleep 8
done
```

## Authentication and rate limits

### There is no API key

The analysis endpoints are public and unauthenticated. There is no key to request, no account to create, and no header to send. Access is governed by per-IP rate limits instead of credentials.

Two request properties do decide whether a call is accepted, and both trip up naive clients:

| Header | Rule | Why |
| --- | --- | --- |
| `User-Agent` | Required, and at least 10 characters. | Empty or one-word agent strings are almost always scrapers. Send something that identifies your project and a way to reach you. |
| `Origin` | If present, must be themesniffer.com or localhost. | Stops other sites embedding the endpoints as a free backend. Server-to-server callers send no `Origin` at all and are unaffected — this only constrains browser calls from a third-party page. |

**Calling from your own web app?** Browsers set `Origin` automatically, so a `fetch()` from `yourapp.com` to the analysis endpoints is rejected with HTTP 403. Call from your backend, or use the MCP endpoint, which accepts any origin by design.

### Rate limits

Limits are per IP address, per rolling minute. A limited request returns HTTP 429 with a JSON body; there is no `Retry-After` header, so wait a minute and retry.

| Endpoint | Requests per minute |
| --- | --- |
| `/api/check-wordpress` | 10 |
| `/api/tech-stack` | 8 |
| `/api/font-detector` | 8 |
| `/api/color-palette` | 8 |
| `/api/mcp` | 30 JSON-RPC requests |

Successful analyses are cacheable for 300 seconds (`Cache-Control: public, max-age=300`); errors and refusals are never cached. Repeating the same URL inside that window is close to free.

### Fair use

Every call makes ThemeSniffer fetch a third-party website on your behalf, so heavy use costs someone else bandwidth as well as us. Cache aggressively, do not re-scan unchanged sites, and if you need volume beyond these limits, [get in touch](https://themesniffer.com/contact) rather than routing around them.

## Endpoint reference

Base URL: `https://themesniffer.com/api`. Every analysis endpoint is a `GET` that takes one query parameter, `url`, and responds with JSON.

The `url` parameter accepts a bare domain. `example.com`, `https://example.com`, and `https://example.com/` all resolve to the same request. Redirects are followed and re-validated at every hop, and the response reports the final URL that was actually analyzed.

GET /api/check-wordpress

Is this website WordPress? The lightest endpoint: one page fetch plus an optional probe of `/wp-json/`.

#### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `isWordPress` | boolean or null | True when at least one signal matched, false when none did, and **`null` when the page could not be read at all** - a block, a bot challenge, a dead origin. Never treat `null` as "not WordPress": see `blocked` and `note`. |
| `blocked`, `blockedBy` | boolean, string or null | `blocked` is true when the site refused the request or served a bot challenge. `blockedBy` names the CDN or WAF when its headers identify it, for example `"Cloudflare"`. |
| `reason`, `status` | string, integer | Why the page could not be read (`bot-protection`, `challenge`, `rate-limited`, `unauthorized`, `not-found`, `origin-down`, `server-error`, `not-html`) and the HTTP status behind it. Both absent on a completed analysis. |
| `note` | string or null | One sentence a person can read: what stopped the check, and whether the site is blocking or actually down. |
| `poweredBy` | string or null | The raw `X-Powered-By` header, unfiltered - usually a PHP version. The `xPoweredBy` signal is `false` unless that value names WordPress; this field is how you tell an absent header from one that simply says something else. |
| `confidence` | string | `high` with three or more signals or a WordPress generator tag, `medium` with two, `low` with one, `none` otherwise. |
| `signals` | object | The eight checks, each `false` or truthy. `metaGenerator` and `xPoweredBy` carry the matched value rather than `true`, and each signal is named for what it tests, not for where it looks: `xPoweredBy` is false on a site sending `x-powered-by: PHP/8.1`, because the header exists but a PHP version is not WordPress evidence (`/api/tech-stack` reports that raw header as `poweredBy`). `linkHeader` and `xPingback` match either the HTTP header or the equivalent `<link>` tag in the document head - a page-cache plugin serves the tags without ever setting the headers. |
| `signalCount` | integer | How many of `totalSignals` matched. |
| `theme` | object or null | Theme name and slug, enriched with version, author, screenshot and install count when the slug is published on WordPress.org. `nameSource` says where the name came from: `style.css` (read from the theme itself), `wordpress.org`, or `slug` (inferred from the folder name, so a guess). `wpOrgUrl` is present only when `inRepo` is true - custom and premium themes have no directory page. See [theme versions and install counts](https://themesniffer.com/developers#theme-versions). |
| `checkedAt` | ISO 8601 | When the analysis ran. |

#### Theme versions and install counts

Four fields on `theme` are easy to read as one thing and are not:

-   `version` is what the site has installed, read from its own `style.css`. `versionSource` says so; when `style.css` could not be fetched it falls back to the directory's release and `versionSource` reads `wordpress.org`.
-   `latestVersion` is the current release in the WordPress.org directory, and `outdated` compares the two - but only when the installed version really was read from the site, because comparing the directory's release against itself would call every theme current.
-   `activeInstalls` is how many sites run the theme, and WordPress.org buckets it: `500000` means "500,000+", never exactly 500,000. Display it with the plus.
-   `downloads` is lifetime downloads, typically an order of magnitude larger, and is not a measure of current usage.

Human version of this endpoint: [the Is It WordPress? checker](https://themesniffer.com/tools/is-it-wordpress/).

GET /api/tech-stack

The full report, and the right default when the question is open-ended. Adds plugins, hosting, CDN, server, security headers, page composition and a speed snapshot to everything `/api/check-wordpress` returns.

#### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `theme` | object or null | As above, including `inRepo` and `nameSource`. |
| `blocked`, `blockedBy`, `reason`, `note` | as above | Same refusal fields as `/api/check-wordpress`, and `isWordPress` is `null` for the same reason. |
| `plugins` | array | Each entry has `slug`, `name` and `known`. Recognised slugs get their real product name; unrecognised ones are title-cased from the slug. |
| `hosting`, `cdn` | object or null | `{ name, type, via }` where `via` names the response header the match came from. Null means no signature matched, not that there is no host. |
| `server`, `poweredBy` | string or null | The raw `Server` and `X-Powered-By` response headers, unfiltered - `poweredBy` is usually the PHP version. |
| `performance` | object | `score` 0-100 with a letter `grade`, `serverResponseMs`, `ttfbRating`, `htmlKb`, `compression`, `requests`. Measured from our edge to the site: a lab snapshot, not the visitor's Core Web Vitals. `htmlKb` is the uncompressed document, and `compression`/`compressed` are `null` when the wire encoding was not observable from our edge - that means undetermined, not "compression is off", and it is excluded from `score` rather than counted against it. |
| `security` | object | Booleans for HTTPS, HSTS, CSP, `X-Frame-Options` and `X-Content-Type-Options`. |

Human version: [the tech stack report](https://themesniffer.com/tools/wordpress-tech-stack/). Manual version: [how to check a WordPress tech stack by hand](https://themesniffer.com/blog/how-to-check-wordpress-tech-stack).

GET /api/font-detector

Every font the site uses. Fetches the page, collects its stylesheets within a byte and time budget, and parses `@font-face` rules, provider URLs and font-family declarations. Works on any website, WordPress or not.

#### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `fonts` | array | One entry per family: `family`, `source`, `weights`, `styles`, `variable`, `isPrimary`, `fallbacks`, `files`. |
| `sources` | object | Per-provider detection: `google`, `bunny`, `fontshare`, `adobe` (with `kitId`), `fontsCom`, `selfHosted` (with `fileCount`). |
| `systemStacks` | array | System font stacks declared in the CSS, kept separate from real families. |
| `wpFontPresets` | array | Font presets from the theme's `theme.json`, when present. |
| `typeScale` | array | Distinct `font-size` values as `{ value, count }`, ascending by pixel equivalent. |
| `stylesheets` | object | How much CSS was reachable: `found`, `fetched`, `failed`, `inlineBlocks`, `cssBytes`. A low `fetched` means a thinner answer. |

Human version: [the font detector](https://themesniffer.com/tools/wordpress-font-detector/).

GET /api/color-palette

The site's palette, parsed out of its CSS rather than sampled from a screenshot, so the values are the ones the designer actually wrote. Handles hex, `rgb()`, `hsl()`, `oklch()` and `oklab()`, and resolves custom properties.

#### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `palette` | array | Clustered colors with `hex`, `rgb`, `hsl`, occurrence `count`, the `roles` the color plays and its `topRole`. |
| `roles` | object | The single best candidate for `background`, `text`, `primary`, `accent` and `border`. Any of them can be null. |
| `wpPalette` | array | Colors declared in the theme's `theme.json` presets, with slug and name. |
| `gradients` | array | Gradient declarations found in the CSS. |
| `contrast` | array | WCAG contrast ratios for the pairs that matter: body text on the background, brand color as text, and label colors on a brand-colored button. |
| `stats` | object | Declarations parsed, unique colors, clusters after grouping, and custom properties seen. |

Human version: [the color palette extractor](https://themesniffer.com/tools/wordpress-color-palette/).

POST /api/mcp

The MCP server. JSON-RPC 2.0 over HTTP, documented in full [below](https://themesniffer.com/developers#mcp).

## Sandbox

Run a real request against the live API without leaving the page. Same endpoints, same rate limits, no key. Pick an endpoint, give it a site, and read the raw JSON it returns.

Ready.

#### Equivalent request

```
curl "https://themesniffer.com/api/tech-stack?url=wordpress.org"
```

#### Response

```
Send a request to see the response.
```

## Errors

Errors are JSON, never HTML, and always carry a message written for a person to read. The shape is stable:

```
{ "success": false, "error": "Cannot analyze private, local, or reserved addresses." }
```

Check `success` rather than the status code alone — a slow or hostile target can produce a body with `success: false`.

| Status | Cause | What to do |
| --- | --- | --- |
| `400` | Missing, malformed, or over-long `url`, or an address in a private, local or reserved range. | Fix the input. Retrying will not help. |
| `403` | `User-Agent` missing or under 10 characters, or an `Origin` from a third-party site. | Send a descriptive `User-Agent`; call from a backend rather than a browser on another domain. |
| `429` | Per-IP rate limit exceeded for that endpoint. | Back off for a minute. Cache results so you do not ask twice. |
| `502` | The target website could not be fetched at all: the connection failed or timed out. A site that answers and refuses is not a 502 - see [when the target blocks us](https://themesniffer.com/developers#errors) below. | Retry once after a pause. |

### When the target blocks us

A site that refuses our request is a different thing from a site with nothing to report, and the API never conflates them. When the page cannot be read, the response is still `200` with `success: true`, but the verdict fields are `null` rather than `false` and the refusal is stated:

```
{
  "success": true,
  "isWordPress": null,
  "confidence": "unknown",
  "blocked": true,
  "blockedBy": "Cloudflare",
  "status": 403,
  "reason": "bot-protection",
  "note": "Cloudflare, which fronts this site, blocked our request (HTTP 403), so we could not check it for WordPress. That is a bot-protection rule, not an outage - the site itself is up and serves normal browsers.",
  "signalCount": 0,
  "url": "https://example.com/",
  "checkedAt": "2026-09-08T10:15:00.000Z"
}
```

So: branch on `blocked` (or `isWordPress === null`) before reading a verdict. A bot challenge served with a `200` is detected too, so an interstitial never gets analyzed as if it were the site.

### Blocked addresses

Requests to private, loopback, link-local, CGNAT and cloud metadata addresses are refused, and so are the alternate encodings that usually smuggle them past a check: integer and hexadecimal IPs, octal-style octets, and IPv4-mapped IPv6. Redirects are re-validated on every hop, so a public URL cannot bounce a request onto an internal network.

## MCP server

ThemeSniffer runs a remote [Model Context Protocol](https://modelcontextprotocol.io) server, so an AI assistant can analyze websites as a first-class tool instead of being told to browse. It speaks Streamable HTTP at a single endpoint:

```
https://themesniffer.com/api/mcp
```

### Connect a client

For a client that supports remote MCP servers over HTTP, add:

```
{
  "mcpServers": {
    "themesniffer": {
      "type": "http",
      "url": "https://themesniffer.com/api/mcp"
    }
  }
}
```

For a client that only speaks stdio, bridge it:

```
{
  "mcpServers": {
    "themesniffer": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://themesniffer.com/api/mcp"]
    }
  }
}
```

No authentication is configured, so there is no token to paste. The server is stateless: it issues no `Mcp-Session-Id`, and it opens no SSE stream, so a `GET` to the endpoint returns 405 by design. Send JSON-RPC with `POST`.

### Tools

| Tool | Argument | Returns |
| --- | --- | --- |
| `check_if_wordpress` | `url` | Verdict, confidence, the eight signals, and the theme. |
| `get_wordpress_tech_stack` | `url` | Theme, plugins, hosting, CDN, server, security headers, speed. |
| `detect_website_fonts` | `url` | Families, providers, system stacks, presets, type scale. |
| `extract_color_palette` | `url` | Palette, role assignments, theme.json colors, gradients, contrast. |

Each tool answers twice: prose in `content` for the model to read, and the complete API response in `structuredContent` for anything that parses.

### MCP Apps: interactive views

Every tool declares a UI template through the MCP Apps extension, so a host that supports it renders a real interface in the conversation — a swatch grid for the palette, a signal chip row for the WordPress verdict — instead of printing JSON. Tools carry the pointer in `_meta`:

```
{
  "name": "extract_color_palette",
  "inputSchema": { "type": "object", "properties": { "url": { "type": "string" } } },
  "_meta": {
    "ui": {
      "resourceUri": "ui://themesniffer/extract_color_palette",
      "visibility": ["app"]
    }
  }
}
```

Reading that resource returns the view as `text/html;profile=mcp-app`. The server advertises the capability during `initialize`:

```
{
  "capabilities": {
    "tools": { "listChanged": false },
    "resources": { "listChanged": false, "subscribe": false },
    "extensions": {
      "io.modelcontextprotocol/ui": {
        "mimeTypes": ["text/html;profile=mcp-app"]
      }
    }
  }
}
```

Inside the frame the view does the standard bridge handshake over `postMessage` — `ui/initialize`, then it waits for `ui/notifications/tool-result` and draws the payload. It reads the host theme, and its one action, opening the full report on this site, goes through `ui/open-link` rather than a raw anchor. A host that ignores the extension still gets the text summary, so nothing is lost.

### Try it with curl

```
curl -sS https://themesniffer.com/api/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

curl -sS https://themesniffer.com/api/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"name":"check_if_wordpress","arguments":{"url":"wordpress.org"}}}'
```

Supported methods: `initialize`, `ping`, `tools/list`, `tools/call`, `resources/list`, `resources/templates/list`, `resources/read`, `prompts/list`. Protocol versions `2025-06-18`, `2025-03-26` and `2024-11-05` are negotiated during `initialize`.

Failures come back as a tool result with `isError: true` and a readable message, not as a JSON-RPC protocol error — an unreachable website is an answer the model can act on, not a broken call.

## WebMCP: tools inside the page

A browser agent that lands on a ThemeSniffer page does not have to work out which input is the URL field and which div holds the result. Every page that can run an analysis registers the same four tools through [WebMCP](https://github.com/webmachinelearning/webmcp), so the agent calls a function and gets structured data back.

```
document.modelContext.registerTool({
  name: 'check_if_wordpress',
  description: 'Check whether a website is built with WordPress…',
  inputSchema: {
    type: 'object',
    properties: { url: { type: 'string' } },
    required: ['url'],
  },
  async execute({ url }) {
    // …calls /api/check-wordpress and returns
    // { content: [{ type: 'text', text }], structuredContent }
  },
});
```

Registration falls back through `navigator.modelContext.registerTool()` and then `navigator.modelContext.provideContext()` for agents on earlier drafts of the proposal. Where no surface exists the script is inert and the forms behave exactly as they always did. The implementation is one file, [/webmcp.js](https://themesniffer.com/webmcp.js), and the tool names and schemas match the MCP server exactly, so an agent that has used one already knows the other.

### No WebMCP host? Call them anyway

Most agents driving a browser today have no `modelContext` to register into, so `window.ThemeSnifferWebMCP.supported` reads `false` and `surface` is `null`. That is the expected state, and it does not mean the tools are unavailable - it only means nothing native picked them up. Any agent that can run JavaScript in the page calls them directly, with the same arguments and the same result shape:

```
await window.ThemeSnifferWebMCP.callTool('check_if_wordpress', { url: 'example.com' });
// { content: [{ type: 'text', text }], structuredContent: { … } }

window.ThemeSnifferWebMCP.tools.map(t => t.name);
// ['check_if_wordpress', 'get_wordpress_tech_stack', 'detect_website_fonts', 'extract_color_palette']
```

A call that could not read the target site comes back with `isError: true` and a text summary that says who blocked it, rather than an empty report that reads like a finding.

The tool forms also carry declarative `toolname` and `tooldescription` attributes, which give an agent server-rendered evidence of what the page can do before any script has run. That part of the proposal is still a preview, so it supplements the registered tools rather than replacing them.

When an agent calls a tool on a page that has the matching form, the page renders the result too, so a human watching the screen sees the same answer the agent got.

## Machine-readable discovery

Everything an agent needs to find and read this site without guessing:

#### OpenAPI 3.1

[/openapi.json](https://themesniffer.com/openapi.json) describes every endpoint, parameter, response schema and error, with worked examples. Linked from this page's head as `rel="service-desc"`.

#### llms.txt

[/llms.txt](https://themesniffer.com/llms.txt) summarises what the product is, what the tools do, and where the Markdown twins live.

#### Markdown twins

Every page has a Markdown version at the same path with a `.md` extension — this page is [/developers.md](https://themesniffer.com/developers.md). Roughly 75% smaller than the HTML, and advertised in each page's head as `rel="alternate"`.

#### MCP

`POST /api/mcp` for tool access, with MCP Apps `ui://` views for hosts that render them.

## What detection cannot see

Worth knowing before you build a product on top of these numbers, because no external scanner can do better:

-   **Plugins with no front-end footprint are invisible.** Detection reads the CSS and JS a page loads. A backup plugin, an SMTP plugin or a security plugin that ships no asset leaves no trace to find.
-   **Bundled and minified assets hide their origin.** A site that concatenates all plugin CSS into one file has erased the paths detection relies on, so it will look emptier than it is.
-   **Speed numbers are a lab snapshot.** They are measured from our edge to the site once. They are useful for comparing sites in the same run, not as a substitute for field Core Web Vitals.
-   **Hosting and CDN come from header signatures.** A null result means no signature matched, not that a site has no host. A CDN in front of the origin usually masks the origin host entirely.
-   **Bot filters win.** A site behind an aggressive WAF refuses us the same way it would any automated client. The endpoint says so rather than guessing - `blocked: true`, the blocker named where its headers identify it, and no verdict - but it cannot see past the wall.
-   **The CSS budget is finite.** The font and color endpoints fetch stylesheets up to a byte and time cap. Check `stylesheets.fetched` against `stylesheets.found` to see whether you got the whole picture.

## Support and changes

The API is versionless. New fields get added to responses; existing fields are not renamed or removed without a note here. Treat unknown fields as additive and do not depend on key order.

Questions, a bug in a detection result, or a volume need beyond the published limits: [the contact page](https://themesniffer.com/contact) or [@ruslan\_dev\_ai](https://x.com/ruslan_dev_ai). A report that names the URL you analyzed and what you expected is worth ten that do not.

### Changelog

| Date | Change |
| --- | --- |
| 2026-09-09 | `theme.activeInstalls` now carries the directory's active-install count; it previously carried lifetime downloads, roughly 20x larger. Added `theme.downloads`, `theme.latestVersion`, `theme.versionSource` and `theme.outdated`, and `poweredBy` on `/api/tech-stack`. |
| 2026-09-09 | A site that blocks or challenges the request no longer returns a verdict: `isWordPress` is `null` and the response carries `blocked`, `blockedBy`, `reason` and `note`. Theme names are read from the theme's own `style.css` where reachable (`nameSource` says which), and `wpOrgUrl` is present only for themes really in the directory. The REST API and pingback signals now count the `<link>` tags as well as the headers. |
| 2026-09-08 | Developer portal published. Added the MCP server at `/api/mcp` with MCP Apps `ui://` views, WebMCP tool registration on the tool pages, and the OpenAPI 3.1 spec at `/openapi.json`. |
