@nekuda/webmcp-sdk 0.5.0 → 0.6.0

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,90 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 — 2026-09-18
4
+
5
+ Five additive changes, no breaking change. The wire schema stays at `2`. Four of them
6
+ are opt-in options — `builtWith`, `pages`, `sessionId` and `createCallTracker` — each
7
+ silent when unset, so **an npm consumer who sets none of them sends exactly the bytes
8
+ 0.5.0 sent**. The fifth is a non-enumerable mark this SDK writes on the tool objects it
9
+ registers under a batch that is already posting to the collect edge; it is invisible to
10
+ enumeration and to `JSON` and never reaches the wire, so it changes no bytes either.
11
+
12
+ One behaviour is **CDN-build-only**. Page-level telemetry sampling is switched on by a
13
+ `bun build --define`, which only `infra/deploy-snippet.sh` passes when it builds the CDN
14
+ snippet; neither publish lane passes it, so **an npm install is unsampled** and sends
15
+ every page-level event, exactly as before.
16
+
17
+ ### Page-level telemetry sampling — CDN builds only
18
+
19
+ - A build may sample the two page-level telemetry events (`sdk_init`,
20
+ `tool_registration`) with `--define '__WEBMCP_TELEMETRY_SAMPLE_RATE__="0.1"'`. The
21
+ decision is one coin per page load from the `sessionId`, so a page's events are kept
22
+ or dropped together, and every kept event carries `sampleRate` for weighting.
23
+ `tool_call` is never sampled. Unset, or anything outside (0, 1), sends everything,
24
+ which is the npm default and the previous behaviour byte for byte. The CDN snippet
25
+ builds at `0.1`: installed sites had reached five million page-level beacons a day.
26
+
27
+ ### `tracking.builtWith`
28
+
29
+ - **`TrackingOptions.builtWith`** names what generated the integration, as
30
+ `<tool>[/<path>]@<version>` (`webmcp-kit/implement@<plugin version>`,
31
+ `webmcp-kit/connect-existing-tools@<plugin version>`), and is reported verbatim
32
+ as `config.builtWith` on `tool_registration`. Additive and optional: unset emits
33
+ exactly the bytes it did before. It exists so kit-built sites are countable on
34
+ the default-on channel without a Connect. Dropped, never truncated, when empty
35
+ or over 64 characters.
36
+
37
+ ### Page-scoped tools
38
+
39
+ - `defineTool({ pages })` filters registration on load and SPA navigation; `matchPage`
40
+ and `currentPageKey` expose the fixture-pinned matching rule. SDK metadata stays off
41
+ the native tool object. `ready` covers the initial filtered set; `current()` and
42
+ `onChange` expose live names. Unregistration uses native abort signals, with legacy
43
+ `unregisterTool` support and explicit retention on surfaces supporting neither.
44
+
45
+ ### A host may own the session and track its own tools
46
+
47
+ Two additive `tracking`-channel surfaces for a host that owns the page it runs on
48
+ (the CDN snippet). Nothing existing changes: both are opt-in, both are silent
49
+ under the same `trackingOutputs` gate as everything else on this channel, and a
50
+ consumer that sets neither emits exactly the bytes it did before.
51
+
52
+ - **`TrackingOptions.sessionId`** reports under a session identity the host
53
+ already has — a tab session that predates any `registerTools` call, or a
54
+ Journey runner's synthetic `syn_…` id — instead of the one this channel mints
55
+ in `sessionStorage` under an `apiKey`-derived namespace, which would split one
56
+ visit into two sessions the pipeline cannot rejoin. Supplying it means no
57
+ `sessionStorage` read or write at all for that batch, `last_seen` included, so
58
+ the 30-minute inactivity boundary becomes the host's to enforce; `visitorId` is
59
+ untouched. Validated like a stored id (non-empty, ≤ 64 chars, a string) —
60
+ anything else falls back to the minted session, because identity fields skip the
61
+ truncation ladder.
62
+ - **`createCallTracker(tool, tracking)`** emits the request/response pair for a
63
+ tool the host registered on `document.modelContext` itself, in the same bytes an
64
+ SDK-registered tool's call produces — same event names, same correlated `callId`,
65
+ same anonymous identity — so the projection reads one shape rather than two. The
66
+ host owns the rest: one tracker per invocation, `tool_call_request` before the
67
+ handler and `tool_call_response` after, and the `duration_ms` it reports. The
68
+ tool's `name` is its `stableKey`, since a host-wrapped tool has no
69
+ developer-authored durable identity to carry — and that rule is applied *over*
70
+ the caller's object, so a `stableKey` riding in on a tool descriptor from a page
71
+ the host does not control cannot key that tool differently from every other
72
+ reader of the same page.
73
+
74
+ ### A tracked tool says so, for a host that owns the surface
75
+
76
+ - A tool registered by a batch that posts to the collect edge now carries the registry
77
+ symbol `Symbol.for("webmcp.sdk.tracked")` (non-enumerable, value `true`) on the object
78
+ handed to `registerTool`. It exists for a host that owns the page's WebMCP surface and
79
+ wraps what it finds there — the CDN snippet's coexistence gate — which could not
80
+ otherwise tell a tool whose calls this SDK already reports from a merchant's bare one,
81
+ and reported every such invocation a second time. Nothing else changes: the mark is
82
+ written only when the channel really is posting (an unkeyed batch, an empty
83
+ `tracking: {}`, a closed consent gate and an otel-only batch carry none), it is
84
+ invisible to enumeration and to `JSON`, and no export is added. It is the one change
85
+ here that is not an option a consumer sets — but being non-enumerable it reaches
86
+ neither the wire nor `JSON.stringify`, so no bytes move.
87
+
3
88
  ## 0.5.0 — 2026-08-24 — usage telemetry schema 2 (breaking wire format)
4
89
 
5
90
  The usage-telemetry channel emits **three events instead of two**, carrying
@@ -65,6 +150,17 @@ unchanged. See `docs/telemetry-schema.md`.
65
150
 
66
151
  ### Added
67
152
 
153
+ - **One `console.error` when a configured publishable key is refused.** The telemetry
154
+ route now answers `401` for a key that was *sent* and did not resolve; the beacon is
155
+ still recorded, anonymously, so nothing is lost but the attribution. The SDK reports
156
+ that at most once per page load — naming the SDK, saying recording continues
157
+ anonymously before saying what broke, and pointing at re-running Connect. It never
158
+ echoes the key or anything off the wire. No throw, no retry, no second send, and no
159
+ change to the send path. Anonymous beacons are never inspected (they have no key to
160
+ be wrong about), and every other status, a network failure, and an environment with
161
+ no `console` stay silent as before. This narrows 0.2.0's "the SDK is silent by
162
+ default": it is the only output the package produces, and only a broken key produces
163
+ it.
68
164
  - **`globalThis.__WEBMCP_TELEMETRY__ = false`** silences the channel page-wide
69
165
  without touching a `registerTools` call site — the only lever a site has when the
70
166
  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,14 +49,89 @@ 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
 
@@ -72,7 +139,7 @@ function resolveModelContext(g = globalThis) {
72
139
  var INGEST_BASE = "https://ingest.agentlane.com";
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);
@@ -105,9 +186,13 @@ function sendTelemetry(event, scope = globalThis, endpoint, apiKey) {
105
186
  return;
106
187
  const url = endpoint || DEFAULT_TELEMETRY_ENDPOINT;
107
188
  const headers = { "content-type": "application/json" };
108
- if (typeof apiKey === "string" && apiKey.trim().length > 0)
189
+ const authenticated = typeof apiKey === "string" && apiKey.trim().length > 0;
190
+ if (authenticated)
109
191
  headers["x-api-key"] = apiKey;
110
- tryFetch(scope, url, headers, json);
192
+ tryFetch(scope, url, headers, json, authenticated ? (response) => {
193
+ if (isUnauthorized(response))
194
+ warnKeyRejected(scope);
195
+ } : undefined);
111
196
  } catch {}
112
197
  }
113
198
  var OTEL_LOGGER_NAME = "@nekuda/webmcp-sdk";
@@ -281,6 +366,14 @@ function getOrCreateSessionId(namespace) {
281
366
  return id;
282
367
  return fallbackId(memorySession, namespace, id);
283
368
  }
369
+ function resolveSessionId(options, namespace) {
370
+ try {
371
+ const supplied = options.sessionId;
372
+ if (typeof supplied === "string" && usableId(supplied))
373
+ return supplied;
374
+ } catch {}
375
+ return getOrCreateSessionId(namespace);
376
+ }
284
377
  function trackingOutputs(options) {
285
378
  try {
286
379
  if (!options || options.disabled)
@@ -331,9 +424,17 @@ function buildEventPayload(params) {
331
424
  eventName: params.eventName,
332
425
  ts: new Date().toISOString(),
333
426
  ...pageFields(),
334
- ...params.data
427
+ ...params.data,
428
+ ...isSyntheticTester() ? { syntheticTester: true } : {}
335
429
  };
336
430
  }
431
+ function isSyntheticTester() {
432
+ try {
433
+ return /^nekuda-synthetic-tester\/[0-9]/i.test(globalThis.navigator?.userAgent ?? "");
434
+ } catch {
435
+ return false;
436
+ }
437
+ }
337
438
  var MAX_EVENT_BYTES = 64 * 1024;
338
439
  var MAX_ERROR_BYTES = 16 * 1024;
339
440
  var TRUNCATABLE = ["response", "input", "error"];
@@ -469,7 +570,7 @@ function track(options, eventName, data, sinks = defaultSinks) {
469
570
  const namespace = storageNamespace(options.apiKey);
470
571
  const event = boundEventPayload(buildEventPayload({
471
572
  visitorId: getOrCreateVisitorId(namespace),
472
- sessionId: getOrCreateSessionId(namespace),
573
+ sessionId: resolveSessionId(options, namespace),
473
574
  eventName,
474
575
  data
475
576
  }));
@@ -761,6 +862,7 @@ var TELEMETRY_FIELDS = {
761
862
  event: true,
762
863
  ts: true,
763
864
  sessionId: true,
865
+ sampleRate: true,
764
866
  "sdk.name": true,
765
867
  "sdk.version": true,
766
868
  "sdk.installMode": true,
@@ -785,6 +887,7 @@ var TELEMETRY_FIELDS = {
785
887
  "config.trackingEnabled": true,
786
888
  "config.otelEnabled": true,
787
889
  "config.customEndpoint": true,
890
+ "config.builtWith": true,
788
891
  tools: true,
789
892
  callId: true,
790
893
  callIndex: true,
@@ -805,6 +908,8 @@ var TELEMETRY_FIELDS = {
805
908
  var TELEMETRY_TOOL_FIELDS = {
806
909
  name: true,
807
910
  stableKey: true,
911
+ inventoryToolId: true,
912
+ contractRevision: true,
808
913
  version: true,
809
914
  schemaHash: true,
810
915
  source: true,
@@ -999,9 +1104,20 @@ function shapeMetrics(inputSchema) {
999
1104
 
1000
1105
  // src/telemetry.ts
1001
1106
  var SDK_NAME = "@nekuda/webmcp-sdk";
1002
- var SDK_VERSION = "0.5.0";
1107
+ var SDK_VERSION = "0.6.0";
1003
1108
  var INSTALL_MODES = ["npm", "cdn_snippet"];
1004
1109
  var SDK_INSTALL_MODE = INSTALL_MODES.find((mode) => mode === (typeof __WEBMCP_INSTALL_MODE__ === "string" ? __WEBMCP_INSTALL_MODE__ : "")) ?? "npm";
1110
+ function parseSampleRate(raw) {
1111
+ const rate = typeof raw === "string" ? Number(raw) : Number.NaN;
1112
+ return Number.isFinite(rate) && rate > 0 && rate < 1 ? rate : 1;
1113
+ }
1114
+ var SDK_TELEMETRY_SAMPLE_RATE = parseSampleRate(typeof __WEBMCP_TELEMETRY_SAMPLE_RATE__ === "string" ? __WEBMCP_TELEMETRY_SAMPLE_RATE__ : undefined);
1115
+ var SAMPLED_EVENTS = new Set(["sdk_init", "tool_registration"]);
1116
+ function pageSampled(sessionId, rate = SDK_TELEMETRY_SAMPLE_RATE) {
1117
+ if (rate >= 1)
1118
+ return true;
1119
+ return Number.parseInt(fnv1a(`sample:${sessionId}`), 16) / 4294967296 < rate;
1120
+ }
1005
1121
  function isFieldParent(value) {
1006
1122
  return typeof value === "object" && value !== null && !Array.isArray(value);
1007
1123
  }
@@ -1122,12 +1238,15 @@ function buildInitEvent(params = {}) {
1122
1238
  }
1123
1239
  function configFields(tracking) {
1124
1240
  const { toBackend, toOtel } = trackingOutputs(tracking);
1241
+ const builtWith = safe(() => tracking?.builtWith);
1125
1242
  return {
1126
1243
  trackingEnabled: toBackend,
1127
1244
  otelEnabled: toOtel,
1128
- customEndpoint: Boolean(safe(() => tracking?.endpoint))
1245
+ customEndpoint: Boolean(safe(() => tracking?.endpoint)),
1246
+ ...typeof builtWith === "string" && builtWith.length > 0 && builtWith.length <= MAX_BUILT_WITH_LENGTH ? { builtWith } : {}
1129
1247
  };
1130
1248
  }
1249
+ var MAX_BUILT_WITH_LENGTH = 64;
1131
1250
  var MAX_TOOL_FIELD_BYTES = 4 * 1024;
1132
1251
  function toolString(value) {
1133
1252
  return sliceToBytes(value, MAX_TOOL_FIELD_BYTES);
@@ -1135,6 +1254,9 @@ function toolString(value) {
1135
1254
  function toolEnum(allowed, value) {
1136
1255
  return allowed.find((candidate) => candidate === value);
1137
1256
  }
1257
+ function toolRevision(value) {
1258
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
1259
+ }
1138
1260
  var ANNOTATION_HINTS = ["readOnlyHint", "untrustedContentHint"];
1139
1261
  function annotationHints(annotations) {
1140
1262
  const hints = {};
@@ -1156,6 +1278,8 @@ function toolEntry(entry) {
1156
1278
  return {
1157
1279
  name: toolString(tool.name),
1158
1280
  stableKey: toolString(tool.stableKey),
1281
+ ...typeof tool.inventoryToolId === "string" ? { inventoryToolId: toolString(tool.inventoryToolId) } : {},
1282
+ ...toolRevision(tool.contractRevision) !== undefined ? { contractRevision: toolRevision(tool.contractRevision) } : {},
1159
1283
  ...tool.version !== undefined ? { version: toolString(tool.version) } : {},
1160
1284
  ...hash !== undefined ? { schemaHash: hash } : {},
1161
1285
  ...source !== undefined ? { source } : {},
@@ -1253,6 +1377,8 @@ function buildToolCallEvent(params) {
1253
1377
  ...sinceInit !== undefined ? { timeSinceInitMs: sinceInit } : {},
1254
1378
  tool: {
1255
1379
  stableKey: toolString(params.tool.stableKey),
1380
+ ...typeof params.tool.inventoryToolId === "string" ? { inventoryToolId: toolString(params.tool.inventoryToolId) } : {},
1381
+ ...toolRevision(params.tool.contractRevision) !== undefined ? { contractRevision: toolRevision(params.tool.contractRevision) } : {},
1256
1382
  ...hash !== undefined ? { schemaHash: hash } : {},
1257
1383
  ...intent !== undefined ? { intent } : {}
1258
1384
  },
@@ -1353,11 +1479,17 @@ function telemetryTenantScope(apiKey) {
1353
1479
  const resolved = resolveTelemetryKey(apiKey);
1354
1480
  return resolved === undefined ? "" : fnv1a(resolved);
1355
1481
  }
1356
- function emitTelemetry(build, sinks = defaultSinks2, fields = TELEMETRY_FIELDS, toolFields = TELEMETRY_TOOL_FIELDS) {
1482
+ function emitTelemetry(build, sinks = defaultSinks2, fields = TELEMETRY_FIELDS, toolFields = TELEMETRY_TOOL_FIELDS, sampleRate = SDK_TELEMETRY_SAMPLE_RATE) {
1357
1483
  try {
1358
1484
  if (!telemetryEnabled())
1359
1485
  return;
1360
- sinks.sendTelemetry(boundEventPayload(pruneByAllowlist({ ...build() }, fields, toolFields)));
1486
+ const event = { ...build() };
1487
+ if (SAMPLED_EVENTS.has(String(event.event)) && sampleRate < 1) {
1488
+ if (!pageSampled(String(event.sessionId), sampleRate))
1489
+ return;
1490
+ event.sampleRate = sampleRate;
1491
+ }
1492
+ sinks.sendTelemetry(boundEventPayload(pruneByAllowlist(event, fields, toolFields)));
1361
1493
  } catch {}
1362
1494
  }
1363
1495
  var initCancelled = false;
@@ -1445,15 +1577,18 @@ function clock() {
1445
1577
  const perf = safe(() => globalThis.performance);
1446
1578
  const now = safe(() => perf?.now);
1447
1579
  if (typeof now === "function") {
1448
- const read2 = () => safe(() => now.call(perf));
1449
- const start2 = read2();
1450
- if (start2 !== undefined)
1451
- return () => elapsedMs(read2(), start2);
1580
+ const read = () => safe(() => now.call(perf));
1581
+ const start = read();
1582
+ if (start !== undefined)
1583
+ return () => elapsedMs(read(), start);
1452
1584
  }
1453
1585
  const read = () => safe(() => Date.now());
1454
1586
  const start = read();
1455
1587
  return () => elapsedMs(read(), start);
1456
1588
  }
1589
+ function createCallTracker(tool, tracking) {
1590
+ return trackerFor({ ...tool, stableKey: tool.name }, tracking);
1591
+ }
1457
1592
  function trackerFor(tool, tracking) {
1458
1593
  if (!tracking)
1459
1594
  return;
@@ -1468,9 +1603,24 @@ function trackerFor(tool, tracking) {
1468
1603
  };
1469
1604
  return (eventName, data) => track(tracking, eventName, { ...shared, ...data });
1470
1605
  }
1606
+ var TRACKED_TOOL_FLAG = Symbol.for("webmcp.sdk.tracked");
1607
+ function markTracked(spec, tracking) {
1608
+ const { toBackend } = trackingOutputs(tracking);
1609
+ if (!toBackend)
1610
+ return spec;
1611
+ try {
1612
+ Object.defineProperty(spec, TRACKED_TOOL_FLAG, {
1613
+ value: true,
1614
+ writable: false,
1615
+ configurable: true,
1616
+ enumerable: false
1617
+ });
1618
+ } catch {}
1619
+ return spec;
1620
+ }
1471
1621
  function toSpecTool(tool, channels) {
1472
- const { tracking, telemetry, telemetrySinks, telemetryKey, telemetryEndpoint: telemetryEndpoint2 } = channels;
1473
- return {
1622
+ const { tracking, telemetry, telemetrySinks, telemetryKey, telemetryEndpoint } = channels;
1623
+ const spec = {
1474
1624
  name: tool.name,
1475
1625
  ...tool.title !== undefined ? { title: tool.title } : {},
1476
1626
  description: tool.description,
@@ -1482,7 +1632,7 @@ function toSpecTool(tool, channels) {
1482
1632
  return normalizeResult(await tool.execute(input));
1483
1633
  const callKey = telemetry ? resolveTelemetryKey(telemetryKey) : undefined;
1484
1634
  const sequence = telemetry ? nextCall(tool.stableKey, telemetryTenantScope(callKey)) : undefined;
1485
- const callSinks = sequence ? batchTelemetrySinks(callKey, telemetryEndpoint2) : telemetrySinks;
1635
+ const callSinks = sequence ? batchTelemetrySinks(callKey, telemetryEndpoint) : telemetrySinks;
1486
1636
  const elapsed = clock();
1487
1637
  trackCall?.("tool_call_request", { input });
1488
1638
  try {
@@ -1506,6 +1656,7 @@ function toSpecTool(tool, channels) {
1506
1656
  }
1507
1657
  }
1508
1658
  };
1659
+ return markTracked(spec, tracking);
1509
1660
  }
1510
1661
  var REGISTRATION_TIMEOUT_MS = 2000;
1511
1662
  var PAGEHIDE = "pagehide";
@@ -1521,7 +1672,7 @@ function onPagehide(run) {
1521
1672
  safe(() => remove.call(g, PAGEHIDE, listener));
1522
1673
  };
1523
1674
  }
1524
- function watchRegistration(tools, tracking, sinks) {
1675
+ function watchRegistration(tools, tracking, sinks, navigation = false) {
1525
1676
  const entries = tools.map((tool) => ({ tool, outcome: "pending" }));
1526
1677
  const elapsed = clock();
1527
1678
  const { registrationIndex, trigger } = nextRegistration();
@@ -1536,7 +1687,7 @@ function watchRegistration(tools, tracking, sinks) {
1536
1687
  const settleMs = elapsed();
1537
1688
  emitTelemetry(() => buildToolRegistrationEvent({
1538
1689
  registrationIndex,
1539
- trigger,
1690
+ trigger: navigation ? "spa_navigation" : trigger,
1540
1691
  settleMs,
1541
1692
  tools: entries,
1542
1693
  tracking
@@ -1608,32 +1759,89 @@ function registerTools(tools, options = {}) {
1608
1759
  state,
1609
1760
  ...error !== undefined ? { error } : {}
1610
1761
  });
1611
- const watch = channels.telemetry ? watchRegistration(tools, channels.tracking, channels.telemetrySinks) : undefined;
1762
+ const active = new Map;
1763
+ const current = () => tools.filter((tool) => active.get(tool)?.registered).map((tool) => tool.name);
1764
+ const changed = () => {
1765
+ safe(() => options.onChange?.(current()));
1766
+ };
1767
+ const eligible = (tool) => {
1768
+ const page = currentPageKey();
1769
+ return page === undefined || matchPage(tool.pages, page);
1770
+ };
1771
+ const remove = (tool) => {
1772
+ const entry = active.get(tool);
1773
+ if (!entry?.removable)
1774
+ return;
1775
+ entry.lifetime.abort();
1776
+ safe(() => modelContext?.unregisterTool?.(tool.name));
1777
+ active.delete(tool);
1778
+ };
1612
1779
  const settle = async (tool) => {
1613
1780
  if (!modelContext)
1614
1781
  return result(tool, "unsupported");
1615
1782
  if (controller.signal.aborted)
1616
1783
  return result(tool, "aborted");
1784
+ const entry = {
1785
+ lifetime: tool.pages?.length ? new AbortController : controller,
1786
+ removable: typeof modelContext.unregisterTool === "function",
1787
+ registered: false
1788
+ };
1789
+ active.set(tool, entry);
1617
1790
  try {
1618
1791
  await Promise.resolve(modelContext.registerTool(toSpecTool(tool, channels), {
1619
- signal: controller.signal
1792
+ get signal() {
1793
+ entry.removable = true;
1794
+ return entry.lifetime.signal;
1795
+ }
1620
1796
  }));
1621
- return result(tool, "registered");
1797
+ if (entry.lifetime.signal.aborted)
1798
+ return result(tool, "aborted");
1799
+ entry.registered = true;
1800
+ if (!eligible(tool))
1801
+ remove(tool);
1802
+ changed();
1803
+ return result(tool, entry.lifetime.signal.aborted ? "aborted" : "registered");
1622
1804
  } catch (error) {
1623
- if (controller.signal.aborted)
1805
+ if (active.get(tool) === entry)
1806
+ active.delete(tool);
1807
+ if (entry.lifetime.signal.aborted)
1624
1808
  return result(tool, "aborted");
1625
1809
  return result(tool, "failed", error);
1626
1810
  }
1627
1811
  };
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());
1812
+ const register = (batch, navigation = false) => {
1813
+ const watch = channels.telemetry ? watchRegistration(batch, channels.tracking, channels.telemetrySinks, navigation) : undefined;
1814
+ const ready = Promise.all(batch.map(async (tool, index) => {
1815
+ const settled = await settle(tool);
1816
+ watch?.record(index, settled);
1817
+ return settled;
1818
+ }));
1819
+ if (watch)
1820
+ ready.then(() => watch.emit());
1821
+ return ready;
1822
+ };
1823
+ const reconcile = () => {
1824
+ if (controller.signal.aborted)
1825
+ return;
1826
+ for (const tool of active.keys())
1827
+ if (!eligible(tool))
1828
+ remove(tool);
1829
+ changed();
1830
+ const added = tools.filter((tool) => eligible(tool) && !active.has(tool));
1831
+ if (added.length)
1832
+ register(added, true);
1833
+ };
1834
+ const stop = !controller.signal.aborted && tools.some((tool) => tool.pages?.length) ? onPageChange(reconcile) : () => {};
1835
+ controller.signal.addEventListener("abort", () => {
1836
+ stop();
1837
+ for (const tool of active.keys())
1838
+ remove(tool);
1839
+ changed();
1840
+ }, { once: true });
1841
+ const ready = register(tools.filter(eligible));
1635
1842
  return {
1636
1843
  ready,
1844
+ current,
1637
1845
  signal: controller.signal,
1638
1846
  unregister() {
1639
1847
  controller.abort();
@@ -1641,7 +1849,10 @@ function registerTools(tools, options = {}) {
1641
1849
  };
1642
1850
  }
1643
1851
  export {
1852
+ createCallTracker,
1853
+ currentPageKey,
1644
1854
  defineTool,
1855
+ matchPage,
1645
1856
  registerTools,
1646
1857
  resolveModelContext
1647
1858
  };
@@ -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,6 +70,13 @@ 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
+ * A `401` on this path means the key that WAS sent resolved to nothing; the beacon
74
+ * itself was still recorded, anonymously. That is reported through
75
+ * {@link warnKeyRejected} and nothing else — no throw, no retry, no second send.
76
+ * The response is only inspected for an authenticated beacon: an anonymous one has
77
+ * no key to be wrong about, so a `401` there would be a backend fault the developer
78
+ * can do nothing with, and blaming their key for it is worse than silence.
79
+ *
68
80
  * The event stays a bare `object` here so this module never has to import the
69
81
  * assembled shape from `telemetry.ts` (which imports this function).
70
82
  */
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",
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",