@nekuda/webmcp-sdk 0.5.0 → 0.6.0-dev.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,104 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased — telemetry beacons without a preflight
4
+
5
+ - The telemetry beacon is sent as `text/plain` instead of `application/json`. The
6
+ body is unchanged; the header makes an anonymous beacon a CORS simple request, so
7
+ the browser stops sending an `OPTIONS` preflight before every event. Keyed beacons
8
+ still carry `x-api-key` and still preflight. The edge has mapped `text/plain` to
9
+ the same template since ADR-0014, so no server change is needed.
10
+
11
+ ## 0.6.0 — 2026-09-18
12
+
13
+ Five additive changes, no breaking change. The wire schema stays at `2`. Four of them
14
+ are opt-in options — `builtWith`, `pages`, `sessionId` and `createCallTracker` — each
15
+ silent when unset, so **an npm consumer who sets none of them sends exactly the bytes
16
+ 0.5.0 sent**. The fifth is a non-enumerable mark this SDK writes on the tool objects it
17
+ registers under a batch that is already posting to the collect edge; it is invisible to
18
+ enumeration and to `JSON` and never reaches the wire, so it changes no bytes either.
19
+
20
+ Beside them is a **mechanism nothing switches on**: page-level telemetry sampling exists
21
+ as a `bun build --define`, and no build this release ships passes a rate. **Every
22
+ install, npm and CDN snippet alike, is unsampled** and sends every page-level event,
23
+ exactly as 0.5.0 did.
24
+
25
+ ### Page-level telemetry sampling — available, off everywhere
26
+
27
+ - A build **may** sample the two page-level telemetry events (`sdk_init`,
28
+ `tool_registration`) with `--define '__WEBMCP_TELEMETRY_SAMPLE_RATE__="0.1"'`. The
29
+ decision is one coin per page load from the `sessionId`, so a page's events are kept
30
+ or dropped together, and every kept event carries `sampleRate` for weighting.
31
+ `tool_call` is never sampled. Unset, `1`, or anything outside (0, 1), sends
32
+ everything — the previous behaviour byte for byte.
33
+ - **Nothing opts in.** Neither publish lane passes the define, and the CDN snippet
34
+ build (`infra/deploy-snippet.sh`) defines the rate as `1`. These two events carry
35
+ the connects, registered-tool counts, refreshes and unique user agents the product
36
+ counts active users from, so they ship complete; the beacon volume gets a fast-path
37
+ ingest queue rather than a tenth of the observations. Turning sampling on for one
38
+ deploy is `SNIPPET_TELEMETRY_SAMPLE_RATE=<rate>` in front of that script, and the
39
+ kept events then say what rate they were kept at.
40
+
41
+ ### `tracking.builtWith`
42
+
43
+ - **`TrackingOptions.builtWith`** names what generated the integration, as
44
+ `<tool>[/<path>]@<version>` (`webmcp-kit/implement@<plugin version>`,
45
+ `webmcp-kit/connect-existing-tools@<plugin version>`), and is reported verbatim
46
+ as `config.builtWith` on `tool_registration`. Additive and optional: unset emits
47
+ exactly the bytes it did before. It exists so kit-built sites are countable on
48
+ the default-on channel without a Connect. Dropped, never truncated, when empty
49
+ or over 64 characters.
50
+
51
+ ### Page-scoped tools
52
+
53
+ - `defineTool({ pages })` filters registration on load and SPA navigation; `matchPage`
54
+ and `currentPageKey` expose the fixture-pinned matching rule. SDK metadata stays off
55
+ the native tool object. `ready` covers the initial filtered set; `current()` and
56
+ `onChange` expose live names. Unregistration uses native abort signals, with legacy
57
+ `unregisterTool` support and explicit retention on surfaces supporting neither.
58
+
59
+ ### A host may own the session and track its own tools
60
+
61
+ Two additive `tracking`-channel surfaces for a host that owns the page it runs on
62
+ (the CDN snippet). Nothing existing changes: both are opt-in, both are silent
63
+ under the same `trackingOutputs` gate as everything else on this channel, and a
64
+ consumer that sets neither emits exactly the bytes it did before.
65
+
66
+ - **`TrackingOptions.sessionId`** reports under a session identity the host
67
+ already has — a tab session that predates any `registerTools` call, or a
68
+ Journey runner's synthetic `syn_…` id — instead of the one this channel mints
69
+ in `sessionStorage` under an `apiKey`-derived namespace, which would split one
70
+ visit into two sessions the pipeline cannot rejoin. Supplying it means no
71
+ `sessionStorage` read or write at all for that batch, `last_seen` included, so
72
+ the 30-minute inactivity boundary becomes the host's to enforce; `visitorId` is
73
+ untouched. Validated like a stored id (non-empty, ≤ 64 chars, a string) —
74
+ anything else falls back to the minted session, because identity fields skip the
75
+ truncation ladder.
76
+ - **`createCallTracker(tool, tracking)`** emits the request/response pair for a
77
+ tool the host registered on `document.modelContext` itself, in the same bytes an
78
+ SDK-registered tool's call produces — same event names, same correlated `callId`,
79
+ same anonymous identity — so the projection reads one shape rather than two. The
80
+ host owns the rest: one tracker per invocation, `tool_call_request` before the
81
+ handler and `tool_call_response` after, and the `duration_ms` it reports. The
82
+ tool's `name` is its `stableKey`, since a host-wrapped tool has no
83
+ developer-authored durable identity to carry — and that rule is applied *over*
84
+ the caller's object, so a `stableKey` riding in on a tool descriptor from a page
85
+ the host does not control cannot key that tool differently from every other
86
+ reader of the same page.
87
+
88
+ ### A tracked tool says so, for a host that owns the surface
89
+
90
+ - A tool registered by a batch that posts to the collect edge now carries the registry
91
+ symbol `Symbol.for("webmcp.sdk.tracked")` (non-enumerable, value `true`) on the object
92
+ handed to `registerTool`. It exists for a host that owns the page's WebMCP surface and
93
+ wraps what it finds there — the CDN snippet's coexistence gate — which could not
94
+ otherwise tell a tool whose calls this SDK already reports from a merchant's bare one,
95
+ and reported every such invocation a second time. Nothing else changes: the mark is
96
+ written only when the channel really is posting (an unkeyed batch, an empty
97
+ `tracking: {}`, a closed consent gate and an otel-only batch carry none), it is
98
+ invisible to enumeration and to `JSON`, and no export is added. It is the one change
99
+ here that is not an option a consumer sets — but being non-enumerable it reaches
100
+ neither the wire nor `JSON.stringify`, so no bytes move.
101
+
3
102
  ## 0.5.0 — 2026-08-24 — usage telemetry schema 2 (breaking wire format)
4
103
 
5
104
  The usage-telemetry channel emits **three events instead of two**, carrying
@@ -65,6 +164,17 @@ unchanged. See `docs/telemetry-schema.md`.
65
164
 
66
165
  ### Added
67
166
 
167
+ - **One `console.error` when a configured publishable key is refused.** The telemetry
168
+ route now answers `401` for a key that was *sent* and did not resolve; the beacon is
169
+ still recorded, anonymously, so nothing is lost but the attribution. The SDK reports
170
+ that at most once per page load — naming the SDK, saying recording continues
171
+ anonymously before saying what broke, and pointing at re-running Connect. It never
172
+ echoes the key or anything off the wire. No throw, no retry, no second send, and no
173
+ change to the send path. Anonymous beacons are never inspected (they have no key to
174
+ be wrong about), and every other status, a network failure, and an environment with
175
+ no `console` stay silent as before. This narrows 0.2.0's "the SDK is silent by
176
+ default": it is the only output the package produces, and only a broken key produces
177
+ it.
68
178
  - **`globalThis.__WEBMCP_TELEMETRY__ = false`** silences the channel page-wide
69
179
  without touching a `registerTools` call site — the only lever a site has when the
70
180
  calls come from generated code it does not edit. Strictly `false`, like GPC is
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @nekuda/webmcp-sdk
2
+
3
+ Define tools with `defineTool`, then pass them to `registerTools(tools, options)`.
4
+ `ready` resolves with initial per-tool outcomes; `unregister()` or `options.signal`
5
+ ends the batch. Browsers without a WebMCP surface report `unsupported`.
6
+
7
+ ## Page-scoped tools
8
+
9
+ Set `pages: ["/products/*"]` on a tool definition to register it only on matching
10
+ pages. Missing/empty `pages` means everywhere; lists match any entry. `*` matches
11
+ any characters within one segment (`/products/*`, `/product.html*`), `**` zero or
12
+ more segments; queries and trailing slashes are ignored on both patterns and locations,
13
+ and repeated slashes collapse to one. Hash
14
+ routers use patterns such as `/#/product/*`. With no readable location (SSR),
15
+ all tools are eligible because the page cannot be evaluated.
16
+
17
+ The SDK reconciles tools on `pushState`, `replaceState`, `popstate`, and
18
+ `hashchange`, sharing one route listener. If History cannot be patched, registration
19
+ still succeeds and navigation detection falls back to `popstate`/`hashchange` only.
20
+ A failing route subscriber never escapes into the merchant's History calls or blocks
21
+ other subscribers. `registration.current()` lists live
22
+ names; `options.onChange(names)` keeps host displays in sync. `ready` covers only
23
+ the initially eligible tools. SDK-only `pages` never reaches native `registerTool`.
24
+
25
+ The [WebMCP draft](https://webmachinelearning.github.io/webmcp/), checked
26
+ 2026-09-10, unregisters via the registration's **AbortSignal**, not
27
+ `unregisterTool`; this works on either resolved modelContext global. Legacy
28
+ surfaces exposing `unregisterTool(name)` are supported too. A surface that neither
29
+ reads the registration signal nor exposes that method keeps a tool once registered;
30
+ the SDK reports it as still live rather than claiming removal. Ending a batch always
31
+ removes its route subscription. Fixtures under `fixtures/` pin matching semantics
32
+ for tests and stay out of the published package.
package/dist/define.d.ts CHANGED
@@ -1,4 +1,22 @@
1
1
  import type { ToolAnnotations } from "./spec.js";
2
+ /**
3
+ * Spec rule: tool names are 1–128 chars of ASCII alphanumerics, `_`, `-`, `.`.
4
+ *
5
+ * Exported for the seam pin only (`tests/tool-key-patterns.test.ts`), not re-exported by
6
+ * `index.ts`: the platform's catalog CHECKs the same shape, and a widening here that the
7
+ * database refuses would reject tools this SDK already accepted in the wild.
8
+ */
9
+ export declare const NAME_PATTERN: RegExp;
10
+ /**
11
+ * `stableKey` rule: dot-namespaced `domain.action` — two or more `[a-z0-9_]+`
12
+ * segments joined by `.`. Rejects the common authoring mistake of copying the
13
+ * wire `name` into `stableKey` (e.g. `search_blog_posts`), which defeats the
14
+ * field's purpose: a `name` can be renamed freely, but a `stableKey` that is
15
+ * just a `name` copy renames right along with it.
16
+ *
17
+ * Exported for the seam pin only — see {@link NAME_PATTERN}.
18
+ */
19
+ export declare const STABLE_KEY_PATTERN: RegExp;
2
20
  /** How the tool came to exist. Reported on telemetry as `tools[].source`. */
3
21
  export type ToolSource = "scanner_generated" | "merchant_authored";
4
22
  /** What the tool is for. Reported on telemetry as `tools[].intent`. */
@@ -33,6 +51,8 @@ export interface ToolDefinition<TInput extends Record<string, unknown> = Record<
33
51
  /** JSON Schema for `execute`'s input, as a plain object. */
34
52
  inputSchema?: Record<string, unknown>;
35
53
  annotations?: ToolAnnotations;
54
+ /** Page patterns where this tool is available; absent/empty means everywhere. SDK-only. */
55
+ pages?: string[];
36
56
  /**
37
57
  * Optional per-tool version, surfaced on emitted tracking events as `toolVersion`
38
58
  * for drift analytics. Free-form string (e.g. semver or a codegen hash); when
@@ -51,6 +71,25 @@ export interface ToolDefinition<TInput extends Record<string, unknown> = Record<
51
71
  * rather than per-batch because one tool answers while another transacts.
52
72
  */
53
73
  intent?: ToolIntent;
74
+ /**
75
+ * The identity the connected platform assigned this tool, if the host knows it —
76
+ * an opaque string the SDK copies and never interprets.
77
+ *
78
+ * `stableKey` is the DEVELOPER's durable identity and survives renames; this is the
79
+ * server's, handed back once a site is connected. Both are reported because they
80
+ * answer different questions: a key the developer chose can collide across two
81
+ * scopes of one site, while the assigned id cannot, and only the developer's key
82
+ * exists before a site is connected at all. Absent is the ordinary state — a tool
83
+ * declared in a codebase that has never been connected simply has no such id, and
84
+ * the SDK never invents one.
85
+ */
86
+ inventoryToolId?: string;
87
+ /**
88
+ * The contract revision the host believes this tool matches, if it knows one.
89
+ * Reported so a stale bundle can be told apart from a tool that genuinely changed
90
+ * shape; never validated here, and never used for anything on the page.
91
+ */
92
+ contractRevision?: number;
54
93
  /**
55
94
  * The page-owned behavior. May return anything JSON-serializable, a plain string,
56
95
  * or a ready-made `{ content: [...] }` result — the SDK normalizes for the agent.
package/dist/index.d.ts CHANGED
@@ -14,8 +14,8 @@
14
14
  * and is never sent to the browser.
15
15
  * 3. Registration lifecycle: `registerTools(tools, { signal?, tracking?, telemetry? })`
16
16
  * registers on call
17
- * and returns `{ ready, unregister, signal }`. Unregistration happens ONLY via
18
- * `unregister()` or the external signal aborting (page teardown / SPA unmount).
17
+ * and returns `{ ready, current, unregister, signal }`. Page-scoped tools also
18
+ * register/unregister as routes change; `unregister()` or an external abort ends the batch.
19
19
  * Browsers without a WebMCP surface are a graceful no-op (`state: "unsupported"`).
20
20
  * 4. "Connect later without rewrite": generated modules only `defineTool` and
21
21
  * EXPORT tools; one entry module calls `registerTools`. Connecting a site to the
@@ -38,7 +38,13 @@
38
38
  * both outputs — but for THIS channel only; the default-on telemetry channel of
39
39
  * point 6 has its own opt-out (`telemetry: false`) and `disabled` does not
40
40
  * narrow it. See `src/tracking.ts`, `src/transport.ts`, and
41
- * `examples/add-to-cart.ts`.
41
+ * `examples/add-to-cart.ts`. `TrackingOptions.sessionId` lets a host that is
42
+ * already the page's session authority report under its own id instead of the
43
+ * one this channel mints; `createCallTracker(tool, tracking)` lets that same
44
+ * host emit the pair for a tool it registered on the surface *itself*, in the
45
+ * same bytes. Both exist for one caller — a host of this SDK that owns the page
46
+ * (the CDN snippet) — and neither is part of what plugin codegen targets:
47
+ * generated code registers tools and reads nothing here.
42
48
  * 6. Anonymous usage telemetry is a SECOND, independent channel: default-ON, so it
43
49
  * reports from every site the SDK runs on, not only from `apiKey` tenants. Three
44
50
  * events (`TelemetryEvent`), all `schema: 2` and joined on an in-memory
@@ -74,7 +80,8 @@
74
80
  * platform stays a config-only change to the entry module (point 4).
75
81
  */
76
82
  export { type AnyWebMCPTool, type ToolDefinition, type ToolIntent, type ToolSource, type WebMCPTool, defineTool, } from "./define.js";
77
- export { type RegisterToolsOptions, type ToolRegistration, type ToolRegistrationResult, type ToolRegistrationState, registerTools, } from "./register.js";
83
+ export { currentPageKey, matchPage } from "./pages.js";
84
+ export { type CallTracker, type RegisterToolsOptions, type ToolRegistration, type ToolRegistrationResult, type ToolRegistrationState, type TrackedCall, createCallTracker, registerTools, } from "./register.js";
78
85
  export { type ModelContextLike, type RegisterToolOptions, type SpecTool, type ToolAnnotations, resolveModelContext, } from "./spec.js";
79
86
  export type { AgentRuntime, FormFactor, FrameContext, PageVisibility, ReferrerClass, SurfaceGlobal, SurfaceInfo, SurfaceProvenance, } from "./telemetry-context.js";
80
87
  export type { ClientContext, InstallMode, PageContext, RegisteredToolEntry, RegistrationTrigger, SdkInfo, SdkInitEvent, StrippedToolEntry, TelemetryEnvelope, TelemetryEvent, TelemetryEventName, ToolCallEvent, ToolCallOutcome, ToolCallResponseMetrics, ToolCallToolInfo, ToolRegistrationEvent, ToolRegistrationOutcome, TrackingConfigInfo, TruncatedTools, } from "./telemetry-events.js";
package/dist/index.js CHANGED
@@ -1,11 +1,3 @@
1
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
- }) : x)(function(x) {
4
- if (typeof require !== "undefined")
5
- return require.apply(this, arguments);
6
- throw Error('Dynamic require of "' + x + '" is not supported');
7
- });
8
-
9
1
  // src/define.ts
10
2
  var NAME_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;
11
3
  var STABLE_KEY_PATTERN = /^[a-z0-9_]+(\.[a-z0-9_]+)+$/;
@@ -57,22 +49,97 @@ function defineTool(definition) {
57
49
  fail(field, `must be one of ${allowed.join(" | ")} when present (got ${JSON.stringify(value)})`);
58
50
  }
59
51
  }
52
+ if (definition.pages !== undefined && (!Array.isArray(definition.pages) || definition.pages.some((page) => typeof page !== "string"))) {
53
+ fail("pages", "must be an array of strings when present");
54
+ }
60
55
  if (typeof execute !== "function") {
61
56
  fail("execute", "must be a function");
62
57
  }
63
58
  return Object.freeze({ ...definition, name });
64
59
  }
60
+ // src/pages.ts
61
+ function pageKey(value) {
62
+ const [path = "", hash = ""] = value.split("#", 2);
63
+ const key = path.split("?", 1)[0] + (hash.startsWith("/") ? `#${hash.split("?", 1)[0]}` : "");
64
+ return `/${key}`.replace(/\/+/g, "/").replace(/\/$/, "");
65
+ }
66
+ function matchPage(patterns, location) {
67
+ if (!patterns?.length)
68
+ return true;
69
+ const key = pageKey(location);
70
+ return patterns.some((pattern) => {
71
+ if (!pattern)
72
+ return true;
73
+ const expression = pageKey(pattern).split("/").slice(1).map((segment) => segment === "**" ? "(?:/[^/]+)*" : `/${segment.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")}`).join("");
74
+ return new RegExp(`^${expression}$`).test(key);
75
+ });
76
+ }
77
+ function currentPageKey() {
78
+ try {
79
+ const location = globalThis.location;
80
+ return location ? `${location.pathname}${location.search ?? ""}${location.hash ?? ""}` : undefined;
81
+ } catch {
82
+ return;
83
+ }
84
+ }
85
+ var ROUTES = Symbol.for("webmcp.sdk.page-routes");
86
+ function onPageChange(listener) {
87
+ if (typeof globalThis.addEventListener !== "function" || !globalThis.history)
88
+ return () => {};
89
+ const history = globalThis.history;
90
+ let routes = history[ROUTES];
91
+ if (!routes) {
92
+ const listeners = new Set;
93
+ routes = {
94
+ listeners,
95
+ notify: () => {
96
+ for (const run of listeners) {
97
+ try {
98
+ run();
99
+ } catch {}
100
+ }
101
+ }
102
+ };
103
+ const { notify } = routes;
104
+ let patched = false;
105
+ try {
106
+ history[ROUTES] = routes;
107
+ for (const method of ["pushState", "replaceState"]) {
108
+ const original = history[method];
109
+ history[method] = function(...args) {
110
+ const result = original.apply(this, args);
111
+ if (patched)
112
+ notify();
113
+ return result;
114
+ };
115
+ }
116
+ patched = true;
117
+ } catch {}
118
+ }
119
+ const { listeners, notify } = routes;
120
+ if (listeners.size === 0) {
121
+ globalThis.addEventListener("popstate", notify);
122
+ globalThis.addEventListener("hashchange", notify);
123
+ }
124
+ listeners.add(listener);
125
+ return () => {
126
+ listeners.delete(listener);
127
+ if (listeners.size === 0) {
128
+ globalThis.removeEventListener("popstate", notify);
129
+ globalThis.removeEventListener("hashchange", notify);
130
+ }
131
+ };
132
+ }
65
133
  // src/spec.ts
66
- function resolveModelContext(g = globalThis) {
67
- const scope = g;
134
+ function resolveModelContext(scope = globalThis) {
68
135
  return scope.document?.modelContext ?? scope.navigator?.modelContext;
69
136
  }
70
137
 
71
138
  // src/transport.ts
72
- var INGEST_BASE = "https://ingest.agentlane.com";
139
+ var INGEST_BASE = "https://ingest.agentlane.dev";
73
140
  var DEFAULT_COLLECT_ENDPOINT = `${INGEST_BASE}/v1/collect`;
74
141
  var DEFAULT_TELEMETRY_ENDPOINT = `${INGEST_BASE}/v1/telemetry`;
75
- function tryFetch(scope, url, headers, json) {
142
+ function tryFetch(scope, url, headers, json, onResponse) {
76
143
  const f = scope.fetch;
77
144
  if (typeof f !== "function")
78
145
  return;
@@ -83,11 +150,25 @@ function tryFetch(scope, url, headers, json) {
83
150
  headers,
84
151
  body: json
85
152
  });
86
- if (result && typeof result.catch === "function") {
87
- result.catch(() => {});
153
+ if (result && typeof result.then === "function") {
154
+ Promise.resolve(result).then((response) => onResponse?.(response)).catch(() => {});
88
155
  }
89
156
  } catch {}
90
157
  }
158
+ var HTTP_UNAUTHORIZED = 401;
159
+ var KEY_REJECTED_MESSAGE = "[@nekuda/webmcp-sdk] The configured publishable key was not accepted. " + "Usage is still being recorded, but anonymously. " + "Re-run Connect to restore attributed reporting.";
160
+ var warnedScopes = new WeakSet;
161
+ function warnKeyRejected(scope) {
162
+ if (warnedScopes.has(scope))
163
+ return;
164
+ warnedScopes.add(scope);
165
+ const write = scope.console?.error;
166
+ if (typeof write === "function")
167
+ write.call(scope.console, KEY_REJECTED_MESSAGE);
168
+ }
169
+ function isUnauthorized(response) {
170
+ return typeof response === "object" && response !== null && response.status === HTTP_UNAUTHORIZED;
171
+ }
91
172
  function sendToCollect(event, config, scope = globalThis) {
92
173
  try {
93
174
  const json = JSON.stringify(event);
@@ -98,16 +179,21 @@ function sendToCollect(event, config, scope = globalThis) {
98
179
  tryFetch(scope, url, headers, json);
99
180
  } catch {}
100
181
  }
182
+ var TELEMETRY_CONTENT_TYPE = "text/plain";
101
183
  function sendTelemetry(event, scope = globalThis, endpoint, apiKey) {
102
184
  try {
103
185
  const json = JSON.stringify(event);
104
186
  if (json === undefined)
105
187
  return;
106
188
  const url = endpoint || DEFAULT_TELEMETRY_ENDPOINT;
107
- const headers = { "content-type": "application/json" };
108
- if (typeof apiKey === "string" && apiKey.trim().length > 0)
189
+ const headers = { "content-type": TELEMETRY_CONTENT_TYPE };
190
+ const authenticated = typeof apiKey === "string" && apiKey.trim().length > 0;
191
+ if (authenticated)
109
192
  headers["x-api-key"] = apiKey;
110
- tryFetch(scope, url, headers, json);
193
+ tryFetch(scope, url, headers, json, authenticated ? (response) => {
194
+ if (isUnauthorized(response))
195
+ warnKeyRejected(scope);
196
+ } : undefined);
111
197
  } catch {}
112
198
  }
113
199
  var OTEL_LOGGER_NAME = "@nekuda/webmcp-sdk";
@@ -281,6 +367,14 @@ function getOrCreateSessionId(namespace) {
281
367
  return id;
282
368
  return fallbackId(memorySession, namespace, id);
283
369
  }
370
+ function resolveSessionId(options, namespace) {
371
+ try {
372
+ const supplied = options.sessionId;
373
+ if (typeof supplied === "string" && usableId(supplied))
374
+ return supplied;
375
+ } catch {}
376
+ return getOrCreateSessionId(namespace);
377
+ }
284
378
  function trackingOutputs(options) {
285
379
  try {
286
380
  if (!options || options.disabled)
@@ -331,9 +425,17 @@ function buildEventPayload(params) {
331
425
  eventName: params.eventName,
332
426
  ts: new Date().toISOString(),
333
427
  ...pageFields(),
334
- ...params.data
428
+ ...params.data,
429
+ ...isSyntheticTester() ? { syntheticTester: true } : {}
335
430
  };
336
431
  }
432
+ function isSyntheticTester() {
433
+ try {
434
+ return /^nekuda-synthetic-tester\/[0-9]/i.test(globalThis.navigator?.userAgent ?? "");
435
+ } catch {
436
+ return false;
437
+ }
438
+ }
337
439
  var MAX_EVENT_BYTES = 64 * 1024;
338
440
  var MAX_ERROR_BYTES = 16 * 1024;
339
441
  var TRUNCATABLE = ["response", "input", "error"];
@@ -469,7 +571,7 @@ function track(options, eventName, data, sinks = defaultSinks) {
469
571
  const namespace = storageNamespace(options.apiKey);
470
572
  const event = boundEventPayload(buildEventPayload({
471
573
  visitorId: getOrCreateVisitorId(namespace),
472
- sessionId: getOrCreateSessionId(namespace),
574
+ sessionId: resolveSessionId(options, namespace),
473
575
  eventName,
474
576
  data
475
577
  }));
@@ -761,6 +863,7 @@ var TELEMETRY_FIELDS = {
761
863
  event: true,
762
864
  ts: true,
763
865
  sessionId: true,
866
+ sampleRate: true,
764
867
  "sdk.name": true,
765
868
  "sdk.version": true,
766
869
  "sdk.installMode": true,
@@ -785,6 +888,7 @@ var TELEMETRY_FIELDS = {
785
888
  "config.trackingEnabled": true,
786
889
  "config.otelEnabled": true,
787
890
  "config.customEndpoint": true,
891
+ "config.builtWith": true,
788
892
  tools: true,
789
893
  callId: true,
790
894
  callIndex: true,
@@ -805,6 +909,8 @@ var TELEMETRY_FIELDS = {
805
909
  var TELEMETRY_TOOL_FIELDS = {
806
910
  name: true,
807
911
  stableKey: true,
912
+ inventoryToolId: true,
913
+ contractRevision: true,
808
914
  version: true,
809
915
  schemaHash: true,
810
916
  source: true,
@@ -999,9 +1105,20 @@ function shapeMetrics(inputSchema) {
999
1105
 
1000
1106
  // src/telemetry.ts
1001
1107
  var SDK_NAME = "@nekuda/webmcp-sdk";
1002
- var SDK_VERSION = "0.5.0";
1108
+ var SDK_VERSION = "0.6.0-dev.17.1";
1003
1109
  var INSTALL_MODES = ["npm", "cdn_snippet"];
1004
1110
  var SDK_INSTALL_MODE = INSTALL_MODES.find((mode) => mode === (typeof __WEBMCP_INSTALL_MODE__ === "string" ? __WEBMCP_INSTALL_MODE__ : "")) ?? "npm";
1111
+ function parseSampleRate(raw) {
1112
+ const rate = typeof raw === "string" ? Number(raw) : Number.NaN;
1113
+ return Number.isFinite(rate) && rate > 0 && rate < 1 ? rate : 1;
1114
+ }
1115
+ var SDK_TELEMETRY_SAMPLE_RATE = parseSampleRate(typeof __WEBMCP_TELEMETRY_SAMPLE_RATE__ === "string" ? __WEBMCP_TELEMETRY_SAMPLE_RATE__ : undefined);
1116
+ var SAMPLED_EVENTS = new Set(["sdk_init", "tool_registration"]);
1117
+ function pageSampled(sessionId, rate = SDK_TELEMETRY_SAMPLE_RATE) {
1118
+ if (rate >= 1)
1119
+ return true;
1120
+ return Number.parseInt(fnv1a(`sample:${sessionId}`), 16) / 4294967296 < rate;
1121
+ }
1005
1122
  function isFieldParent(value) {
1006
1123
  return typeof value === "object" && value !== null && !Array.isArray(value);
1007
1124
  }
@@ -1122,12 +1239,15 @@ function buildInitEvent(params = {}) {
1122
1239
  }
1123
1240
  function configFields(tracking) {
1124
1241
  const { toBackend, toOtel } = trackingOutputs(tracking);
1242
+ const builtWith = safe(() => tracking?.builtWith);
1125
1243
  return {
1126
1244
  trackingEnabled: toBackend,
1127
1245
  otelEnabled: toOtel,
1128
- customEndpoint: Boolean(safe(() => tracking?.endpoint))
1246
+ customEndpoint: Boolean(safe(() => tracking?.endpoint)),
1247
+ ...typeof builtWith === "string" && builtWith.length > 0 && builtWith.length <= MAX_BUILT_WITH_LENGTH ? { builtWith } : {}
1129
1248
  };
1130
1249
  }
1250
+ var MAX_BUILT_WITH_LENGTH = 64;
1131
1251
  var MAX_TOOL_FIELD_BYTES = 4 * 1024;
1132
1252
  function toolString(value) {
1133
1253
  return sliceToBytes(value, MAX_TOOL_FIELD_BYTES);
@@ -1135,6 +1255,9 @@ function toolString(value) {
1135
1255
  function toolEnum(allowed, value) {
1136
1256
  return allowed.find((candidate) => candidate === value);
1137
1257
  }
1258
+ function toolRevision(value) {
1259
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
1260
+ }
1138
1261
  var ANNOTATION_HINTS = ["readOnlyHint", "untrustedContentHint"];
1139
1262
  function annotationHints(annotations) {
1140
1263
  const hints = {};
@@ -1156,6 +1279,8 @@ function toolEntry(entry) {
1156
1279
  return {
1157
1280
  name: toolString(tool.name),
1158
1281
  stableKey: toolString(tool.stableKey),
1282
+ ...typeof tool.inventoryToolId === "string" ? { inventoryToolId: toolString(tool.inventoryToolId) } : {},
1283
+ ...toolRevision(tool.contractRevision) !== undefined ? { contractRevision: toolRevision(tool.contractRevision) } : {},
1159
1284
  ...tool.version !== undefined ? { version: toolString(tool.version) } : {},
1160
1285
  ...hash !== undefined ? { schemaHash: hash } : {},
1161
1286
  ...source !== undefined ? { source } : {},
@@ -1253,6 +1378,8 @@ function buildToolCallEvent(params) {
1253
1378
  ...sinceInit !== undefined ? { timeSinceInitMs: sinceInit } : {},
1254
1379
  tool: {
1255
1380
  stableKey: toolString(params.tool.stableKey),
1381
+ ...typeof params.tool.inventoryToolId === "string" ? { inventoryToolId: toolString(params.tool.inventoryToolId) } : {},
1382
+ ...toolRevision(params.tool.contractRevision) !== undefined ? { contractRevision: toolRevision(params.tool.contractRevision) } : {},
1256
1383
  ...hash !== undefined ? { schemaHash: hash } : {},
1257
1384
  ...intent !== undefined ? { intent } : {}
1258
1385
  },
@@ -1353,11 +1480,17 @@ function telemetryTenantScope(apiKey) {
1353
1480
  const resolved = resolveTelemetryKey(apiKey);
1354
1481
  return resolved === undefined ? "" : fnv1a(resolved);
1355
1482
  }
1356
- function emitTelemetry(build, sinks = defaultSinks2, fields = TELEMETRY_FIELDS, toolFields = TELEMETRY_TOOL_FIELDS) {
1483
+ function emitTelemetry(build, sinks = defaultSinks2, fields = TELEMETRY_FIELDS, toolFields = TELEMETRY_TOOL_FIELDS, sampleRate = SDK_TELEMETRY_SAMPLE_RATE) {
1357
1484
  try {
1358
1485
  if (!telemetryEnabled())
1359
1486
  return;
1360
- sinks.sendTelemetry(boundEventPayload(pruneByAllowlist({ ...build() }, fields, toolFields)));
1487
+ const event = { ...build() };
1488
+ if (SAMPLED_EVENTS.has(String(event.event)) && sampleRate < 1) {
1489
+ if (!pageSampled(String(event.sessionId), sampleRate))
1490
+ return;
1491
+ event.sampleRate = sampleRate;
1492
+ }
1493
+ sinks.sendTelemetry(boundEventPayload(pruneByAllowlist(event, fields, toolFields)));
1361
1494
  } catch {}
1362
1495
  }
1363
1496
  var initCancelled = false;
@@ -1445,15 +1578,18 @@ function clock() {
1445
1578
  const perf = safe(() => globalThis.performance);
1446
1579
  const now = safe(() => perf?.now);
1447
1580
  if (typeof now === "function") {
1448
- const read2 = () => safe(() => now.call(perf));
1449
- const start2 = read2();
1450
- if (start2 !== undefined)
1451
- return () => elapsedMs(read2(), start2);
1581
+ const read = () => safe(() => now.call(perf));
1582
+ const start = read();
1583
+ if (start !== undefined)
1584
+ return () => elapsedMs(read(), start);
1452
1585
  }
1453
1586
  const read = () => safe(() => Date.now());
1454
1587
  const start = read();
1455
1588
  return () => elapsedMs(read(), start);
1456
1589
  }
1590
+ function createCallTracker(tool, tracking) {
1591
+ return trackerFor({ ...tool, stableKey: tool.name }, tracking);
1592
+ }
1457
1593
  function trackerFor(tool, tracking) {
1458
1594
  if (!tracking)
1459
1595
  return;
@@ -1468,9 +1604,24 @@ function trackerFor(tool, tracking) {
1468
1604
  };
1469
1605
  return (eventName, data) => track(tracking, eventName, { ...shared, ...data });
1470
1606
  }
1607
+ var TRACKED_TOOL_FLAG = Symbol.for("webmcp.sdk.tracked");
1608
+ function markTracked(spec, tracking) {
1609
+ const { toBackend } = trackingOutputs(tracking);
1610
+ if (!toBackend)
1611
+ return spec;
1612
+ try {
1613
+ Object.defineProperty(spec, TRACKED_TOOL_FLAG, {
1614
+ value: true,
1615
+ writable: false,
1616
+ configurable: true,
1617
+ enumerable: false
1618
+ });
1619
+ } catch {}
1620
+ return spec;
1621
+ }
1471
1622
  function toSpecTool(tool, channels) {
1472
- const { tracking, telemetry, telemetrySinks, telemetryKey, telemetryEndpoint: telemetryEndpoint2 } = channels;
1473
- return {
1623
+ const { tracking, telemetry, telemetrySinks, telemetryKey, telemetryEndpoint } = channels;
1624
+ const spec = {
1474
1625
  name: tool.name,
1475
1626
  ...tool.title !== undefined ? { title: tool.title } : {},
1476
1627
  description: tool.description,
@@ -1482,7 +1633,7 @@ function toSpecTool(tool, channels) {
1482
1633
  return normalizeResult(await tool.execute(input));
1483
1634
  const callKey = telemetry ? resolveTelemetryKey(telemetryKey) : undefined;
1484
1635
  const sequence = telemetry ? nextCall(tool.stableKey, telemetryTenantScope(callKey)) : undefined;
1485
- const callSinks = sequence ? batchTelemetrySinks(callKey, telemetryEndpoint2) : telemetrySinks;
1636
+ const callSinks = sequence ? batchTelemetrySinks(callKey, telemetryEndpoint) : telemetrySinks;
1486
1637
  const elapsed = clock();
1487
1638
  trackCall?.("tool_call_request", { input });
1488
1639
  try {
@@ -1506,6 +1657,7 @@ function toSpecTool(tool, channels) {
1506
1657
  }
1507
1658
  }
1508
1659
  };
1660
+ return markTracked(spec, tracking);
1509
1661
  }
1510
1662
  var REGISTRATION_TIMEOUT_MS = 2000;
1511
1663
  var PAGEHIDE = "pagehide";
@@ -1521,7 +1673,7 @@ function onPagehide(run) {
1521
1673
  safe(() => remove.call(g, PAGEHIDE, listener));
1522
1674
  };
1523
1675
  }
1524
- function watchRegistration(tools, tracking, sinks) {
1676
+ function watchRegistration(tools, tracking, sinks, navigation = false) {
1525
1677
  const entries = tools.map((tool) => ({ tool, outcome: "pending" }));
1526
1678
  const elapsed = clock();
1527
1679
  const { registrationIndex, trigger } = nextRegistration();
@@ -1536,7 +1688,7 @@ function watchRegistration(tools, tracking, sinks) {
1536
1688
  const settleMs = elapsed();
1537
1689
  emitTelemetry(() => buildToolRegistrationEvent({
1538
1690
  registrationIndex,
1539
- trigger,
1691
+ trigger: navigation ? "spa_navigation" : trigger,
1540
1692
  settleMs,
1541
1693
  tools: entries,
1542
1694
  tracking
@@ -1608,32 +1760,89 @@ function registerTools(tools, options = {}) {
1608
1760
  state,
1609
1761
  ...error !== undefined ? { error } : {}
1610
1762
  });
1611
- const watch = channels.telemetry ? watchRegistration(tools, channels.tracking, channels.telemetrySinks) : undefined;
1763
+ const active = new Map;
1764
+ const current = () => tools.filter((tool) => active.get(tool)?.registered).map((tool) => tool.name);
1765
+ const changed = () => {
1766
+ safe(() => options.onChange?.(current()));
1767
+ };
1768
+ const eligible = (tool) => {
1769
+ const page = currentPageKey();
1770
+ return page === undefined || matchPage(tool.pages, page);
1771
+ };
1772
+ const remove = (tool) => {
1773
+ const entry = active.get(tool);
1774
+ if (!entry?.removable)
1775
+ return;
1776
+ entry.lifetime.abort();
1777
+ safe(() => modelContext?.unregisterTool?.(tool.name));
1778
+ active.delete(tool);
1779
+ };
1612
1780
  const settle = async (tool) => {
1613
1781
  if (!modelContext)
1614
1782
  return result(tool, "unsupported");
1615
1783
  if (controller.signal.aborted)
1616
1784
  return result(tool, "aborted");
1785
+ const entry = {
1786
+ lifetime: tool.pages?.length ? new AbortController : controller,
1787
+ removable: typeof modelContext.unregisterTool === "function",
1788
+ registered: false
1789
+ };
1790
+ active.set(tool, entry);
1617
1791
  try {
1618
1792
  await Promise.resolve(modelContext.registerTool(toSpecTool(tool, channels), {
1619
- signal: controller.signal
1793
+ get signal() {
1794
+ entry.removable = true;
1795
+ return entry.lifetime.signal;
1796
+ }
1620
1797
  }));
1621
- return result(tool, "registered");
1798
+ if (entry.lifetime.signal.aborted)
1799
+ return result(tool, "aborted");
1800
+ entry.registered = true;
1801
+ if (!eligible(tool))
1802
+ remove(tool);
1803
+ changed();
1804
+ return result(tool, entry.lifetime.signal.aborted ? "aborted" : "registered");
1622
1805
  } catch (error) {
1623
- if (controller.signal.aborted)
1806
+ if (active.get(tool) === entry)
1807
+ active.delete(tool);
1808
+ if (entry.lifetime.signal.aborted)
1624
1809
  return result(tool, "aborted");
1625
1810
  return result(tool, "failed", error);
1626
1811
  }
1627
1812
  };
1628
- const ready = Promise.all(tools.map(async (tool, index) => {
1629
- const settled = await settle(tool);
1630
- watch?.record(index, settled);
1631
- return settled;
1632
- }));
1633
- if (watch)
1634
- ready.then(() => watch.emit());
1813
+ const register = (batch, navigation = false) => {
1814
+ const watch = channels.telemetry ? watchRegistration(batch, channels.tracking, channels.telemetrySinks, navigation) : undefined;
1815
+ const ready = Promise.all(batch.map(async (tool, index) => {
1816
+ const settled = await settle(tool);
1817
+ watch?.record(index, settled);
1818
+ return settled;
1819
+ }));
1820
+ if (watch)
1821
+ ready.then(() => watch.emit());
1822
+ return ready;
1823
+ };
1824
+ const reconcile = () => {
1825
+ if (controller.signal.aborted)
1826
+ return;
1827
+ for (const tool of active.keys())
1828
+ if (!eligible(tool))
1829
+ remove(tool);
1830
+ changed();
1831
+ const added = tools.filter((tool) => eligible(tool) && !active.has(tool));
1832
+ if (added.length)
1833
+ register(added, true);
1834
+ };
1835
+ const stop = !controller.signal.aborted && tools.some((tool) => tool.pages?.length) ? onPageChange(reconcile) : () => {};
1836
+ controller.signal.addEventListener("abort", () => {
1837
+ stop();
1838
+ for (const tool of active.keys())
1839
+ remove(tool);
1840
+ changed();
1841
+ }, { once: true });
1842
+ const ready = register(tools.filter(eligible));
1635
1843
  return {
1636
1844
  ready,
1845
+ current,
1637
1846
  signal: controller.signal,
1638
1847
  unregister() {
1639
1848
  controller.abort();
@@ -1641,7 +1850,10 @@ function registerTools(tools, options = {}) {
1641
1850
  };
1642
1851
  }
1643
1852
  export {
1853
+ createCallTracker,
1854
+ currentPageKey,
1644
1855
  defineTool,
1856
+ matchPage,
1645
1857
  registerTools,
1646
1858
  resolveModelContext
1647
1859
  };
@@ -0,0 +1,6 @@
1
+ /** Match any page pattern; undefined/empty lists (and empty patterns) mean everywhere. */
2
+ export declare function matchPage(patterns: readonly string[] | undefined, location: string): boolean;
3
+ /** No readable location (SSR) means cannot evaluate: register all tools, not a root-page match. */
4
+ export declare function currentPageKey(): string | undefined;
5
+ /** One shared browser listener/History patch, including across duplicated SDK bundles. */
6
+ export declare function onPageChange(listener: () => void): () => void;
@@ -16,6 +16,8 @@ export interface ToolRegistrationResult {
16
16
  error?: unknown;
17
17
  }
18
18
  export interface RegisterToolsOptions {
19
+ /** Called after the live name set changes, including initial registration and navigation. */
20
+ onChange?: (names: string[]) => void;
19
21
  /**
20
22
  * External lifetime for the registration (e.g. a component's unmount signal).
21
23
  * Aborting it unregisters every tool in this batch, same as `unregister()`.
@@ -64,18 +66,55 @@ export interface ToolRegistration {
64
66
  * cannot mask the rest.
65
67
  */
66
68
  ready: Promise<ToolRegistrationResult[]>;
69
+ /** Currently registered names, in declaration order; `ready` covers only the initial page. */
70
+ current(): string[];
67
71
  /** Unregister every tool in this batch. Idempotent. */
68
72
  unregister(): void;
69
73
  /** The signal carrying this registration's lifetime (aborted once unregistered). */
70
74
  signal: AbortSignal;
71
75
  }
76
+ /**
77
+ * Emits one invocation's authenticated-channel events. The `callId` correlating a
78
+ * `tool_call_request` with its `tool_call_response` is closed over, so one tracker
79
+ * is one call — never one tool, never one page.
80
+ */
81
+ export type CallTracker = (eventName: string, data: Record<string, unknown>) => void;
82
+ /**
83
+ * A tool a host observes but did not register through {@link registerTools} — the
84
+ * only identity it can offer is the wire `name` the agent surface already knows.
85
+ */
86
+ export interface TrackedCall {
87
+ name: string;
88
+ /** Optional per-tool version, surfaced on the events as `toolVersion`. */
89
+ version?: string;
90
+ }
91
+ /**
92
+ * Emitter for a call on a tool this SDK did not register, or `undefined` when the
93
+ * authenticated channel has no live output — the same `trackingOutputs` answer
94
+ * {@link registerTools} gates its own per-call emitter on, so the two cannot
95
+ * disagree about whether the channel is live.
96
+ *
97
+ * This exists for one caller shape: a host that owns the page's WebMCP surface and
98
+ * wraps tools registered on it directly (the CDN snippet's coexistence path). Those
99
+ * calls must land on `/v1/collect` as the same bytes an SDK-registered tool's do —
100
+ * same event names, same correlated pair, same anonymous identity — so the
101
+ * projection reads one shape rather than two. Everything else the host must do
102
+ * itself: call it once per invocation (a reused tracker collapses two calls into
103
+ * one `callId`), emit `tool_call_request` before the handler and
104
+ * `tool_call_response` after, and measure the `duration_ms` it reports.
105
+ *
106
+ * `stableKey` is the tool's `name`: a host-wrapped tool has no developer-authored
107
+ * durable identity to carry, and inventing one would key the same tool differently
108
+ * from every other reader of the same page.
109
+ */
110
+ export declare function createCallTracker(tool: TrackedCall, tracking: TrackingOptions): CallTracker | undefined;
72
111
  /**
73
112
  * Register a batch of `defineTool` tools on the page's WebMCP surface.
74
113
  *
75
- * Lifecycle: registration starts immediately; the tools stay live until
76
- * `unregister()` is called or the external `options.signal` aborts (both funnel
77
- * into one internal `AbortController`, the spec's only unregistration mechanism).
78
- * On browsers without a WebMCP surface this is a graceful no-op — generated code
79
- * can run unconditionally on every page.
114
+ * Registration starts immediately, filtered by `pages`; `ready` covers that initial set.
115
+ * Route changes reconcile scoped tools using per-tool signals (or legacy unregisterTool).
116
+ * A legacy surface that never reads the signal and has no unregister method retains a
117
+ * tool once registered. `unregister()`/external abort also removes the route subscription.
118
+ * Without a readable location (SSR), all tools are eligible; without a surface, no-op.
80
119
  */
81
120
  export declare function registerTools(tools: readonly AnyWebMCPTool[], options?: RegisterToolsOptions): ToolRegistration;
package/dist/spec.d.ts CHANGED
@@ -11,9 +11,9 @@
11
11
  * - `registerTool(tool, { signal })` returns a promise that settles when the
12
12
  * registration completes; it rejects on a duplicate name, an invalid tool, an
13
13
  * inactive document, or an abort.
14
- * - Unregistration happens ONLY by aborting the `AbortSignal` passed at
15
- * registration. `unregisterTool()` and `provideContext()` are dead APIs and are
16
- * never referenced.
14
+ * - Current unregistration is the `AbortSignal` passed at registration (also
15
+ * verified against the 2026-09-10 draft). Legacy surfaces may instead expose
16
+ * `unregisterTool(name)`; a surface supporting neither retains registered tools.
17
17
  * - Tool names: 1–128 chars of [A-Za-z0-9_\-.].
18
18
  * - `annotations.readOnlyHint` / `annotations.untrustedContentHint`.
19
19
  *
@@ -48,9 +48,20 @@ export interface RegisterToolOptions {
48
48
  */
49
49
  export interface ModelContextLike {
50
50
  registerTool(tool: SpecTool, options?: RegisterToolOptions): Promise<void>;
51
+ /** Pre-draft compatibility only; current native surfaces use the registration signal. */
52
+ unregisterTool?(name: string): void;
53
+ }
54
+ interface GlobalWithModelContext {
55
+ document?: {
56
+ modelContext?: ModelContextLike;
57
+ };
58
+ navigator?: {
59
+ modelContext?: ModelContextLike;
60
+ };
51
61
  }
52
62
  /**
53
63
  * Resolve the page's WebMCP surface; `undefined` on non-supporting browsers.
54
64
  * Injectable global scope for tests.
55
65
  */
56
- export declare function resolveModelContext(g?: object): ModelContextLike | undefined;
66
+ export declare function resolveModelContext(scope?: GlobalWithModelContext): ModelContextLike | undefined;
67
+ export {};
@@ -81,7 +81,17 @@ export interface ClientContext {
81
81
  * even when nobody ever calls `registerTools`: a site that loads the SDK and never
82
82
  * registers is a broken integration, and this event is the only way to see it.
83
83
  */
84
- export interface SdkInitEvent extends TelemetryEnvelope {
84
+ /**
85
+ * Present only when a build sampled the page-level events: the rate this page was kept
86
+ * at, strictly between 0 and 1. Absent means every such event on the page was sent. A
87
+ * consumer counting page loads weights each sampled event by `1 / sampleRate`. Not on
88
+ * the envelope, because `tool_call` never carries it — sampling is per page, and a
89
+ * tool invocation is not a page-level fact.
90
+ */
91
+ export interface SampledEvent {
92
+ sampleRate?: number;
93
+ }
94
+ export interface SdkInitEvent extends TelemetryEnvelope, SampledEvent {
85
95
  event: "sdk_init";
86
96
  sdk: SdkInfo;
87
97
  surface: SurfaceInfo;
@@ -108,6 +118,8 @@ export interface TrackingConfigInfo {
108
118
  trackingEnabled: boolean;
109
119
  otelEnabled: boolean;
110
120
  customEndpoint: boolean;
121
+ /** `TrackingOptions.builtWith`, verbatim, when set and within its length cap. */
122
+ builtWith?: string;
111
123
  }
112
124
  /**
113
125
  * One tool in a `tool_registration` batch. The shape metrics are inherited as
@@ -117,6 +129,14 @@ export interface TrackingConfigInfo {
117
129
  export interface RegisteredToolEntry extends Partial<ToolShapeMetrics> {
118
130
  name: string;
119
131
  stableKey: string;
132
+ /**
133
+ * The connected platform's own id for this tool, when the host supplied one. Opaque
134
+ * and optional: a tool declared in a codebase that has never been connected has none,
135
+ * and its absence is the honest reading rather than a degraded one.
136
+ */
137
+ inventoryToolId?: string;
138
+ /** The contract revision the host believed this tool matched, when it knew one. */
139
+ contractRevision?: number;
120
140
  version?: string;
121
141
  schemaHash?: string;
122
142
  source?: ToolSource;
@@ -154,7 +174,7 @@ export interface TruncatedTools {
154
174
  * outcomes v1 computed and threw away. A site where 3 of 8 tools fail on a
155
175
  * duplicate name is the failure mode this event exists to make visible.
156
176
  */
157
- export interface ToolRegistrationEvent extends TelemetryEnvelope {
177
+ export interface ToolRegistrationEvent extends TelemetryEnvelope, SampledEvent {
158
178
  event: "tool_registration";
159
179
  /** 1-based position of this batch within the page load. */
160
180
  registrationIndex: number;
@@ -176,6 +196,10 @@ export type ToolCallOutcome = "success" | "error";
176
196
  /** `tool.*` on `tool_call` — identity and the two fields worth joining calls on. */
177
197
  export interface ToolCallToolInfo {
178
198
  stableKey: string;
199
+ /** The connected platform's own id, when the host supplied one. See the registration
200
+ * entry's field of the same name — same value, same optionality, same opacity. */
201
+ inventoryToolId?: string;
202
+ contractRevision?: number;
179
203
  schemaHash?: string;
180
204
  intent?: ToolIntent;
181
205
  }
@@ -40,6 +40,7 @@ export declare const TELEMETRY_FIELDS: {
40
40
  readonly event: true;
41
41
  readonly ts: true;
42
42
  readonly sessionId: true;
43
+ readonly sampleRate: true;
43
44
  readonly "sdk.name": true;
44
45
  readonly "sdk.version": true;
45
46
  readonly "sdk.installMode": true;
@@ -64,6 +65,7 @@ export declare const TELEMETRY_FIELDS: {
64
65
  readonly "config.trackingEnabled": true;
65
66
  readonly "config.otelEnabled": true;
66
67
  readonly "config.customEndpoint": true;
68
+ readonly "config.builtWith": true;
67
69
  readonly tools: true;
68
70
  readonly callId: true;
69
71
  readonly callIndex: true;
@@ -90,6 +92,8 @@ export declare const TELEMETRY_FIELDS: {
90
92
  export declare const TELEMETRY_TOOL_FIELDS: {
91
93
  readonly name: true;
92
94
  readonly stableKey: true;
95
+ readonly inventoryToolId: true;
96
+ readonly contractRevision: true;
93
97
  readonly version: true;
94
98
  readonly schemaHash: true;
95
99
  readonly source: true;
@@ -43,6 +43,22 @@ export declare const SDK_VERSION: string;
43
43
  * the overwhelming majority rather than emit a value no consumer can read.
44
44
  */
45
45
  export declare const SDK_INSTALL_MODE: InstallMode;
46
+ /**
47
+ * Parse a sampling rate. Anything that is not a number strictly between 0 and 1 means
48
+ * "send everything": an unset define is how every npm build reaches a page, and a
49
+ * mistyped one must degrade to complete data rather than to silence.
50
+ */
51
+ export declare function parseSampleRate(raw: unknown): number;
52
+ /** The rate this build samples page-level events at; `1` sends everything. */
53
+ export declare const SDK_TELEMETRY_SAMPLE_RATE: number;
54
+ /**
55
+ * Whether this page load is in the sample. Decided from the session id, which every
56
+ * event on the page shares, so `sdk_init` and the `tool_registration`s beside it are
57
+ * kept or dropped together — a sampled page is a complete page, never half of one. The
58
+ * hash is the same FNV-1a the channel already uses for `schemaHash`; its top 32 bits
59
+ * over 2^32 is a uniform enough coin for a rate that only has to hold on average.
60
+ */
61
+ export declare function pageSampled(sessionId: string, rate?: number): boolean;
46
62
  /**
47
63
  * Read a value that may not exist, collapsing both an absent property and a
48
64
  * throwing getter to `undefined`. Every browser global this module touches is
@@ -173,6 +189,9 @@ export declare function buildInitEvent(params?: InitEventParams): SdkInitEvent;
173
189
  export interface RegisteredTelemetryTool {
174
190
  name: string;
175
191
  stableKey: string;
192
+ /** The connected platform's own id for this tool; opaque, copied, never interpreted. */
193
+ inventoryToolId?: string;
194
+ contractRevision?: number;
176
195
  version?: string;
177
196
  description?: string;
178
197
  inputSchema?: unknown;
@@ -187,6 +206,12 @@ export interface RegisteredToolParams {
187
206
  /** What the surface rejected with; templated into `failureSignature` when `failed`. */
188
207
  error?: unknown;
189
208
  }
209
+ /**
210
+ * Cap on `config.builtWith`, in characters. Sized for `<tool>@<semver-with-prerelease>`
211
+ * with room to spare; it is the one caller-supplied string on `tool_registration`
212
+ * that is not copied off a tool, so the per-entry byte cap below does not cover it.
213
+ */
214
+ export declare const MAX_BUILT_WITH_LENGTH = 64;
190
215
  /** Where a batch sits in the page load, and what caused it. */
191
216
  export interface RegistrationSequence {
192
217
  /** 1-based position among the batches this page load reports. */
@@ -278,6 +303,9 @@ export declare function nextCall(stableKey: string, tenantScope?: string): ToolC
278
303
  /** What `tool_call` reads off a tool — the two fields worth joining calls on. */
279
304
  export interface CalledTelemetryTool {
280
305
  stableKey: string;
306
+ /** The connected platform's own id for this tool; opaque, copied, never interpreted. */
307
+ inventoryToolId?: string;
308
+ contractRevision?: number;
281
309
  /** Reported as a hash only; the schema itself never leaves the page. */
282
310
  inputSchema?: unknown;
283
311
  intent?: ToolIntent;
@@ -421,7 +449,7 @@ export declare function telemetryTenantScope(apiKey: unknown): string;
421
449
  * global the builders read is guarded individually too; this is the backstop that
422
450
  * keeps the guarantee true of assembly as a whole, not of each read in turn.
423
451
  */
424
- export declare function emitTelemetry(build: () => TelemetryEvent, sinks?: TelemetrySinks, fields?: TelemetryFieldMap, toolFields?: TelemetryToolFieldMap): void;
452
+ export declare function emitTelemetry(build: () => TelemetryEvent, sinks?: TelemetrySinks, fields?: TelemetryFieldMap, toolFields?: TelemetryToolFieldMap, sampleRate?: number): void;
425
453
  /**
426
454
  * Record the `apiKey` of a `registerTools` call so the deferred `sdk_init` flush —
427
455
  * which runs with no batch in hand, and may run before any batch exists — can
@@ -81,8 +81,39 @@ export declare function getOrCreateSessionId(namespace: string): string;
81
81
  export interface TrackingOptions {
82
82
  /** Presence (non-empty) enables the backend transport and namespaces storage. */
83
83
  apiKey?: string;
84
+ /**
85
+ * What generated this integration, as `<tool>[/<path>]@<version>` —
86
+ * `webmcp-kit/implement@<plugin version>` for the plugin's codegen,
87
+ * `webmcp-kit/connect-existing-tools@<plugin version>` for its migration of
88
+ * hand-written tools. Reported once per batch as `config.builtWith` on the
89
+ * default-on channel so kit-built sites are countable without a Connect; a
90
+ * hand-written integration leaves it unset. Free text under {@link MAX_BUILT_WITH_LENGTH}
91
+ * characters; anything else is dropped rather than truncated, because a partial
92
+ * `<tool>@<vers` is a wrong answer, not a shorter one.
93
+ */
94
+ builtWith?: string;
84
95
  /** Override the collect endpoint; ignored without `apiKey`. */
85
96
  endpoint?: string;
97
+ /**
98
+ * Report under a session identity the host already owns, verbatim, instead of
99
+ * the one this module mints in `sessionStorage`. For a host that is itself the
100
+ * page's session authority — the CDN snippet, whose tab session predates any
101
+ * `registerTools` call, or a Journey runner replaying under a synthetic `syn_…`
102
+ * id — the storage-minted id would split one visit into two sessions the
103
+ * pipeline cannot rejoin, because it lives in an `apiKey`-derived namespace the
104
+ * host does not share.
105
+ *
106
+ * Supplying it takes this module out of the session business entirely for that
107
+ * batch: nothing is read from or written to `sessionStorage`, including
108
+ * `last_seen`, so the 30-minute inactivity boundary is the host's to enforce.
109
+ * The `visitorId` is unaffected — a different lifetime, still this module's.
110
+ *
111
+ * Validated like a stored id ({@link MAX_ID_LENGTH}), because identity fields are
112
+ * copied onto every event without passing through the truncation ladder: an
113
+ * empty, oversized or non-string value falls back to the minted session rather
114
+ * than breaking `boundEventPayload`'s fit guarantee.
115
+ */
116
+ sessionId?: string;
86
117
  /** Emit each event as an OTEL LogRecord via the global `LoggerProvider`. */
87
118
  otel?: boolean;
88
119
  /**
@@ -23,6 +23,11 @@
23
23
  * pages that never key the authenticated one, so a keyless send is the normal
24
24
  * case, not a failure — the backend attributes those by CORS `Origin` instead.
25
25
  *
26
+ * That path has exactly one observable failure signal, and it is a `console.error`,
27
+ * never a throw or a retry: a *keyed* beacon whose key the backend cannot resolve
28
+ * is still recorded (anonymously) and answered `401`, so a site shipping a broken
29
+ * key is otherwise indistinguishable from a working one. See {@link warnKeyRejected}.
30
+ *
26
31
  * The second, independent output is OTEL (`emitOtelLog`): each event becomes a
27
32
  * LogRecord on the global `LoggerProvider` via the optional peer dep
28
33
  * `@opentelemetry/api-logs`. The host app owns exporters and processing; a
@@ -65,9 +70,28 @@ export declare function sendToCollect(event: TrackingEvent, config: CollectConfi
65
70
  * wire. Note that adding the header makes the request non-simple under CORS, so
66
71
  * an authenticated beacon costs a preflight the anonymous one does not.
67
72
  *
73
+ * The body is sent as `text/plain`, not `application/json`, and that is a cost
74
+ * decision, not a formatting one: `text/plain` is a CORS-safelisted content type,
75
+ * so an anonymous beacon is a "simple request" and the browser sends it with no
76
+ * OPTIONS preflight. With `application/json` every page load paid two requests
77
+ * per event at the edge; at 2026-09 volume that preflight was roughly half of the
78
+ * seven million requests a day the collect API billed. The edge maps `text/plain`
79
+ * to the same template as JSON (ingestion-edge/telemetry.tf), so nothing changes
80
+ * on the wire but the header. The keyed beacon keeps `x-api-key`, which forces a
81
+ * preflight regardless of content type; that path is a small fraction of traffic.
82
+ *
83
+ * A `401` on this path means the key that WAS sent resolved to nothing; the beacon
84
+ * itself was still recorded, anonymously. That is reported through
85
+ * {@link warnKeyRejected} and nothing else — no throw, no retry, no second send.
86
+ * The response is only inspected for an authenticated beacon: an anonymous one has
87
+ * no key to be wrong about, so a `401` there would be a backend fault the developer
88
+ * can do nothing with, and blaming their key for it is worse than silence.
89
+ *
68
90
  * The event stays a bare `object` here so this module never has to import the
69
91
  * assembled shape from `telemetry.ts` (which imports this function).
70
92
  */
93
+ /** CORS-safelisted, so an anonymous beacon needs no preflight — see sendTelemetry. */
94
+ export declare const TELEMETRY_CONTENT_TYPE = "text/plain";
71
95
  export declare function sendTelemetry(event: object, scope?: object, endpoint?: string, apiKey?: unknown): void;
72
96
  /** The `Logger.emit` slice we use — structural, no hard OTEL type coupling. */
73
97
  interface LoggerLike {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nekuda/webmcp-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.0-dev.17.1",
4
4
  "type": "module",
5
5
  "description": "Phase-1 WebMCP SDK: a thin wrapper over document.modelContext that plugin-generated code targets — defineTool + register/unregister lifecycle. This package pins the plugin↔SDK seam; anonymous tool-call tracking (backend transport via apiKey, OTEL LogRecords via otel) is opt-in through registerTools and default-silent, while anonymous usage telemetry is a separate unauthenticated channel that is on by default (opt out with telemetry: false).",
6
6
  "main": "./dist/index.js",
@@ -11,7 +11,10 @@
11
11
  "default": "./dist/index.js"
12
12
  }
13
13
  },
14
- "files": ["dist", "CHANGELOG.md"],
14
+ "files": [
15
+ "dist",
16
+ "CHANGELOG.md"
17
+ ],
15
18
  "publishConfig": {
16
19
  "access": "public"
17
20
  },
@@ -36,5 +39,7 @@
36
39
  "optional": true
37
40
  }
38
41
  },
39
- "trustedDependencies": ["@biomejs/biome"]
42
+ "trustedDependencies": [
43
+ "@biomejs/biome"
44
+ ]
40
45
  }