Semantic Hints MCP
by timotheeiss
README.md
# Semantic Hints MCP
A small **local stdio MCP server** that exposes compact semantic observations from
a UI annotated with `data-semtag-*` hints.
It is a **companion to the official Playwright MCP, not a replacement.** It only
*reads* semantics; all browser actions (click, type, navigate) stay in Playwright
MCP. The intended split:
1. `semantic_snapshot` → a compact map of hinted elements (~2–3× smaller than a
Playwright ARIA snapshot of the same page).
2. Pick a semantic `id`, e.g. `checkout.submit`.
3. Act via **Playwright MCP** using the selector `[data-semtag-id='checkout.submit']`.
4. `semantic_observe` → the current value/state of one hinted element.
## Browser / session architecture
The semantic-hints MCP uses the **Playwright library directly**. It does **not**
call the Playwright MCP internally.
However, the semantic-hints MCP and the official Playwright MCP **must observe/control
the same browser state** during an experiment. Avoid accidentally creating two
unrelated browser sessions where:
- Playwright MCP clicks in browser **A**
- semantic-hints MCP observes browser **B**
That would invalidate the workflow.
> **Semantic-hints MCP must not silently launch a separate browser by default.**
### Preferred design: one shared Chromium over CDP
1. Start Chromium with remote debugging enabled, e.g.:
```bash
chromium --remote-debugging-port=9222
```
(or the equivalent using your locally installed Chrome/Chromium binary).
2. Configure the official Playwright MCP to connect to that shared browser, if
supported by the installed version/config.
3. The semantic-hints MCP connects to the **same** browser via Playwright:
```js
chromium.connectOverCDP("http://127.0.0.1:9222")
```
4. It **reuses the active page** where possible. If no suitable page exists and a
URL is provided, it may open/navigate a page. If no page exists and no URL is
provided, it returns a clear error asking for a URL or active page.
### Configuration
| Variable | Default | Purpose |
| ------------------------------ | ----------------------- | ----------------------------------------------------------------------- |
| `SEMANTIC_HINTS_CDP_URL` | `http://127.0.0.1:9222` | CDP endpoint of the shared Chromium to attach to. |
| `SEMANTIC_HINTS_TARGET_URL` | (unset) | Optional default app URL, opened when a tool is called with no `url`. |
| `SEMANTIC_HINTS_LAUNCH_BROWSER`| `false` | If `true`, may launch a private browser when the CDP connection fails. |
| `SEMANTIC_HINTS_HEADLESS` | `true` | Headless mode for the standalone-launch fallback only. |
Behavior:
- If `SEMANTIC_HINTS_CDP_URL` is reachable, connect over CDP (the normal mode).
- If the CDP connection fails and `SEMANTIC_HINTS_LAUNCH_BROWSER` is `false`, **fail
clearly with setup instructions** — never silently launch.
- If `SEMANTIC_HINTS_LAUNCH_BROWSER` is `true`, the server may launch its own browser,
but every tool response then carries a `warning` field making clear this is a
**standalone session that may not share state** with the Playwright MCP.
The normal research workflow should use the shared CDP browser mode.
## Tools
### `semantic_snapshot`
Returns hinted elements grouped **by their `data-semtag-role`** — `navigation`,
`action`, `option`, `input`, `select`, `toggle`, `slider`, `observable`,
`region`, `collection` — plus `other`, `collections`, `url` and `screen`.
Empty groups are omitted, and there is no `counts` block: the arrays state
their own lengths, so repeating them would only spend tokens.
```jsonc
{
"url": "http://localhost:8082/products", // optional: navigate first; else read current page
"scope": "products.filters", // optional: data-semtag-id or CSS selector subtree
"includeHidden": false // optional: include hidden elements (default false)
}
```
Per element (fields included only when present): `id`, `name`, `action`,
`state`, `target`, `controls`, `options` (selects only), `value` (roles that
hold one), `enabled`, `visible`. Never returns HTML, class names, or DOM
subtrees.
There is no `role` field — the group key is the role, stated once for the whole
group instead of once per entry. The exception is `other`, where the key says
nothing: entries there keep the raw `role` string so an off-vocabulary value is
visible rather than silently erased. `semantic_observe` always includes `role`,
having no group around it.
The ten roles and the seven `data-semtag-*` attributes are defined in
`semtag-injector-skill/semtag-inject/references/hint-design.md`, the source of
truth for this server, the
hint-injection prompt, and the browser-agent prompt alike. Nothing is inferred
from tags or ARIA, and nothing is repaired: an element with a
`data-semtag-action` but no `data-semtag-role` is `other`, not an action. `other`
is how a hinting gap surfaces, so it is a thing to go fix in the app.
`enabled` and `visible` are omitted unless `false` — an element carrying neither
is enabled and visible. Since snapshots exclude hidden elements by default, a
`"visible": true` on every entry would only restate the filter.
#### Folded collections
A repeated collection is most of a real snapshot, and nearly all of it is
repetition: every card restates its role and target, every price cell restates
its role and state. So a collection is folded into **one columnar block** —
shared attributes stated once, each item reduced to a row of only what varies:
```jsonc
"collections": [{
"id": "products.grid",
"name": "Products",
"idPattern": "products.grid.item.{key}[.{control}]",
"item": { "role": "navigation" },
"itemControls": { "price": { "role": "observable", "state": "product.price" },
"remove": { "role": "action", "name": "Remove",
"action": "remove-product" } },
"fields": ["key", "name", "target", "price.value", "remove"],
"items": [
["alpha", "Alpha Phone", "product.detail", "£10.00", true],
["beta", "Beta Laptop", "product.detail", "£20.00", true],
["gamma", "Gamma Bundle", "bundle.detail", "£30.00", null]
]
}]
```
- `item` / `itemControls[c]` hold the fields every item agrees on.
(`itemControls`, not `controls`, because the container may carry its own
`data-semtag-controls`.)
- `fields` is the column header: `"key"` first, then the item's own varying
fields by bare name, then `"<control>.<field>"`. `items` rows align to it, and
`null` marks an absent cell.
- An element only *some* items have gets a bare `"<control>"` presence column
holding `true` / `null` — above, `gamma` has no Remove button. It is needed
because a uniform optional control otherwise hoists away completely, leaving
no cell that could be null. The item itself can be optional too, and then
takes a column named `"item"`.
- `name` is dropped from a cell when it equals `value`: for a text observable
the accessible name *is* the displayed value.
- The bucket an element would have occupied is **not** carried. On real pages it
only ever restated `role` (`"navigation"`/`"navigation"`), and `role` with
`action` / `target` / `state` already says everything an agent acts on.
**Folding costs no addressability.** `idPattern` states how to rebuild any
member's real `data-semtag-id` — `products.grid.item.beta.price` — which
`semantic_observe` and `[data-semtag-id="…"]` still resolve exactly as before.
Folded members and their container leave the six flat buckets, so nothing is
stated twice.
Folding needs two things: at least two items using the `<prefix>.item.<key>` id
convention, and a **DOM ancestor** declaring `data-semtag-role="collection"`.
Ancestry, not id equality — a container is often named a little differently from
the prefix its rows use (`admin.products.list` holding
`admin.products.item.42`), and that is a naming nicety, not a reason to give up
the compression. Hence `id` (the container's own hint) and `idPattern` (built
from the rows' prefix) can differ. A repeated group with no collection ancestor
at all is a hinting gap in the app, not something the extractor guesses at: it
stays flat, and the hint-review overlay flags it.
### `semantic_observe`
```jsonc
{ "id": "cart.total" }
```
Resolves `[data-semtag-id="<id>"]` and returns its compact current state. Returns a
clear error if zero or multiple elements match.
## Setup
```bash
cd semantic-hints-mcp
npm install
npx playwright install chromium # one-time browser download
npm run build
```
See [Configuration](#configuration) above for the environment variables.
## Register in Claude Code
From this folder, after `npm run build`:
```bash
claude mcp add semantic-hints \
--env SEMANTIC_HINTS_CDP_URL=http://127.0.0.1:9222 \
--env SEMANTIC_HINTS_TARGET_URL=http://localhost:8080 \
-- node "$(pwd)/dist/index.js"
```
Or add it to `.mcp.json` / your Claude Code config manually:
```jsonc
{
"mcpServers": {
"semantic-hints": {
"command": "node",
"args": ["/absolute/path/to/semantic-hints-mcp/dist/index.js"],
"env": {
"SEMANTIC_HINTS_CDP_URL": "http://127.0.0.1:9222",
"SEMANTIC_HINTS_TARGET_URL": "http://localhost:8080"
}
}
}
}
```
(For development without a build step, use `"command": "npx", "args": ["tsx",
"/abs/path/src/index.ts"]`.)
## Usage example
Expected Claude Code workflow:
1. Start the app under test, e.g.
```bash
npm run dev # → http://localhost:8080 for the annotated WebTestBench app
```
2. Start a shared Chromium with CDP enabled, e.g.
```bash
chromium --remote-debugging-port=9222
```
3. Register/start **both** MCP servers:
- the official Playwright MCP, configured for the shared browser if possible
- the semantic-hints MCP, configured with `SEMANTIC_HINTS_CDP_URL=http://127.0.0.1:9222`
4. Agent calls:
```jsonc
semantic_snapshot({ "url": "http://localhost:8080" })
```
5. Agent interacts using the official Playwright MCP:
```jsonc
browser_click({ "target": "[data-semtag-id='checkout.submit']" })
```
6. Agent calls:
```jsonc
semantic_observe({ "id": "cart.total" })
```
The click (step 5) and observe (step 6) **must operate on the same browser
page/session** — which is exactly what the shared CDP browser guarantees.
## Tests
```bash
npm test
```
Vitest loads `test/fixture.html` in headless Chromium and checks: grouped compact
output, hidden-element handling, `scope`, observable/input value reads, missing &
duplicate-ID errors, and that output contains no raw HTML/DOM.
TDQS
A4.4/5.0
Scored across 2 tools
Disambiguation5/5
The two tools have clearly distinct purposes: semantic_snapshot provides an overview of all hinted elements, while semantic_observe retrieves the state of a single element. No ambiguity.
Naming Consistency5/5
Both tools follow a consistent verb_noun pattern with the 'semantic_' prefix, using snake_case. The naming is predictable and clear.
Tool Count5/5
With only 2 tools, the server is tightly scoped to its purpose of observing semantic hints. This is appropriate for a focused utility; each tool earns its place.
Completeness5/5
The tool surface covers the essential operations for the domain: getting a full map of elements and reading a single element's state. No obvious gaps for an observation-only tool.
Maintenance
ActivityMaintained
ResponsivenessNo issues