diff --git a/domain-skills/svo/zaehlerstandsmeldung.md b/domain-skills/svo/zaehlerstandsmeldung.md new file mode 100644 index 00000000..9183c6fc --- /dev/null +++ b/domain-skills/svo/zaehlerstandsmeldung.md @@ -0,0 +1,69 @@ +# SVO — Zählerstandsmeldung via WebMCP + +SVO's meter-reading form exposes a **WebMCP** tool, so you don't have to drive the DOM. See `interaction-skills/webmcp.md` for the general `navigator.modelContext` mechanics (the `executeTool` arg shape is the part that trips people up). + +URL: `https://svo-test.de/service/zaehlerstandsmeldung` (test environment). + +## Tool + +`report_meter_reading` — fills the existing form's fields. **It never submits**: CAPTCHA + final submit stay a human action, and for privacy it returns no echo of the entered values. So verify by reading the DOM, not by the result text. + +### Input schema + +Required: `zaehlernummer`, `ablesedatum` (`YYYY-MM-DD`), `vorname`, `nachname`, `e_mail_adresse`, `telefonnummer_49`, `haben_sie_einen_ht_nt_zaehler` (boolean). + +Meter-reading fields are **conditional on the tariff flag**: + +- `haben_sie_einen_ht_nt_zaehler: true` → two-rate (HT/NT) meter → fill `zaehlerstand_ht` **and** `zaehlerstand_nt`. The single-rate field is hidden. +- `haben_sie_einen_ht_nt_zaehler: false` → single-rate meter → fill `zaehlerstand` only. + +Never fill both the single-rate and the HT/NT fields. + +## Call it (HT/NT example) + +```python +import json +args = { + "zaehlernummer": "1ESY1160123456", + "ablesedatum": "2026-06-25", + "vorname": "Max", + "nachname": "Mustermann", + "e_mail_adresse": "max.mustermann@example.com", + "telefonnummer_49": "0171 2345678", + "haben_sie_einen_ht_nt_zaehler": True, + "zaehlerstand_ht": "012345", + "zaehlerstand_nt": "006789", +} + +res = js(r'''(async (argsJson) => { + const mc = navigator.modelContext; + let tools = mc.getTools(); + if (tools && typeof tools.then === 'function') tools = await tools; + const tool = tools.find(t => t.name === 'report_meter_reading'); + let r = mc.executeTool(tool, argsJson); // RegisteredTool object + JSON STRING + if (r && typeof r.then === 'function') r = await r; + return r; +})(%s)''' % json.dumps(json.dumps(args))) +print(res) +``` + +## Verify + +The HT/NT toggle is a radio group `haben_sie_einen_ht_nt_zaehler` (HT/NT vs. "Standardzähler"). After the call, read field values to confirm: + +```python +print(js(r'''(() => { + const out = []; + document.querySelectorAll('input,select,textarea').forEach(el => { + if (el.type === 'hidden' || !el.name) return; + out.push({name: el.name, value: (el.type==='checkbox'||el.type==='radio') ? el.checked : el.value}); + }); + return out; +})()''') +``` + +## Notes + +- There's a `honeypot` text input — leave it empty (spam trap; filling it likely flags the submission). +- The page has a fixed notification banner overlaying the lower viewport; `scrollIntoView`/`window.scrollTo` to bring form fields above it before screenshotting. +- Conversational use: read the tool's `inputSchema.required` first, then ask the user for the tariff type + the matching reading(s) before calling. diff --git a/interaction-skills/webmcp.md b/interaction-skills/webmcp.md new file mode 100644 index 00000000..0ae95fac --- /dev/null +++ b/interaction-skills/webmcp.md @@ -0,0 +1,92 @@ +# WebMCP — calling page-exposed tools via `navigator.modelContext` + +Some sites ship **WebMCP**: the page registers agent-callable tools on `navigator.modelContext`. Instead of scraping the DOM or hand-driving clicks, you discover the tool, read its JSON Schema, and call it — the page's own handler does the work (fill a form, run a search, etc.). Treat it the same way you'd treat a private API: prefer it over DOM automation when it's there. + +## Detect + +```python +res = js(r'''(() => { + const mc = window.navigator && navigator.modelContext; + if (!mc) return {webmcp: false}; + return {webmcp: true, api: Object.getOwnPropertyNames(Object.getPrototypeOf(mc) || {})}; +})()''') +print(res) +# {'webmcp': True, 'api': ['ontoolchange', 'executeTool', 'getTools', 'registerTool', 'constructor']} +``` + +The empty enumerable-keys case is normal — the surface lives on the prototype (`getTools`, `executeTool`, `registerTool`, `ontoolchange`). + +## List the registered tools + +`getTools()` may be sync or return a Promise — handle both. Each tool has `name`, `description`, and `inputSchema` (a JSON Schema; **often a JSON-encoded *string*, not an object** — parse it). + +```python +import json +res = js(r'''(async () => { + const mc = navigator.modelContext; + let tools = mc.getTools(); + if (tools && typeof tools.then === 'function') tools = await tools; + return (tools || []).map(t => ({name: t.name, description: t.description, inputSchema: t.inputSchema})); +})()''') +tools = res +for t in tools: + schema = t["inputSchema"] + if isinstance(schema, str): + schema = json.loads(schema) # inputSchema arrives as a JSON string + print(t["name"], "→ required:", schema.get("required")) +``` + +The schema's `required` array tells you exactly which inputs the tool needs — use it to drive a conversational "ask the user for the missing fields" flow before calling. + +## Execute a tool — two non-obvious gotchas + +`navigator.modelContext.executeTool` is **not** `executeTool(name, argsObject)`. Field-tested against Chrome's implementation (June 2026): + +1. **First arg must be the `RegisteredTool` object itself** (the entry you got back from `getTools()`), **not** the tool name as a string. Passing a string → `TypeError: The provided value is not of type 'RegisteredTool'`. +2. **Second arg must be a JSON *string*** of the arguments, **not** a JS object. Passing an object → `UnknownError: Failed to parse input arguments`. + +```python +import json +args = {"field_a": "value", "flag": True} + +res = js(r'''(async (argsJson) => { + const mc = navigator.modelContext; + let tools = mc.getTools(); + if (tools && typeof tools.then === 'function') tools = await tools; + const tool = tools.find(t => t.name === 'TOOL_NAME_HERE'); + if (!tool) return {ok: false, err: 'tool not found'}; + try { + let r = mc.executeTool(tool, argsJson); // (RegisteredTool, jsonString) + if (r && typeof r.then === 'function') r = await r; + return {ok: true, result: r}; + } catch (e) { return {ok: false, err: String(e)}; } +})(%s)''' % json.dumps(json.dumps(args))) # double-encode: a JS string literal holding JSON +print(res) +``` + +The handler typically returns MCP-shaped content, e.g. +`{"content": [{"type": "text", "text": "..."}]}` — also often a JSON string, so parse it. + +## Verify, don't trust the return value + +WebMCP handlers may **intentionally not echo** what they did (privacy: "no entered values are returned"). After calling a tool that fills a form, confirm by reading the DOM yourself rather than relying on the result text: + +```python +res = js(r'''(() => { + const out = []; + document.querySelectorAll('input,select,textarea').forEach(el => { + if (el.type === 'hidden') return; + out.push({name: el.name || el.id, value: (el.type==='checkbox'||el.type==='radio') ? el.checked : el.value}); + }); + return out.filter(o => o.value !== '' && o.value !== false); +})()''') +print(res) +``` + +## Traps + +- `inputSchema` and tool results are **JSON-encoded strings** as often as not — always `json.loads` defensively. +- `getTools()` / `executeTool()` may be sync or async — always guard with `typeof r.then === 'function'`. +- A well-behaved form-filling tool **won't submit** (it leaves CAPTCHA + submit to the human). Don't expect a success/redirect — verify field state instead. +- Tools can change at runtime; `ontoolchange` fires on re-registration. Re-`getTools()` if a tool you expected is missing. +- Cross-tab isolation: `navigator.modelContext` only exists in the tab that registered it. You reach it here because the harness runs JS *in that page* over CDP — a separate browser/agent tab cannot see another tab's `modelContext`.