@nekuda/webmcp-sdk 0.4.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,6 +1,91 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleasedusage telemetry schema 2 (breaking wire format)
3
+ ## 0.6.02026-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
+
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
6
91
  derived signals instead of raw page and payload strings. `schema` bumps `1` → `2`.
@@ -11,11 +96,11 @@ unchanged. See `docs/telemetry-schema.md`.
11
96
 
12
97
  ### Breaking
13
98
 
14
- - **`sdk_init` fires once per page load, not once per `registerTools` call.** It is
15
- scheduled at import time and flushed on the next task, so it now fires even when
16
- nobody registers anything which is the only way a broken integration is
17
- visible. A page with three `registerTools` calls no longer emits three "init"
18
- events.
99
+ - **`sdk_init` fires once per page load, not once per `registerTools` call.** The
100
+ first telemetry-live batch schedules a next-task flush with its own key and
101
+ endpoint; an import-time one-second fallback still fires when nobody registers
102
+ anything, which is the only way a broken integration is visible. A page with
103
+ three `registerTools` calls no longer emits three "init" events.
19
104
  - **Per-call information moved to the new `tool_registration` event**, one per
20
105
  `registerTools` call: `registrationIndex`, `trigger`, `settleMs`, the `config.*`
21
106
  booleans (moved off `sdk_init`, which fires before any batch may have run), and
@@ -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,13 +14,14 @@
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
22
- * platform later (API key, telemetry) changes wrapper internals and config only —
23
- * generated modules and their imports do not change. See `examples/add-to-cart.ts`.
22
+ * platform later adds a publishable tracking key to wrapper config only — generated
23
+ * modules and their imports do not change. The default-on anonymous channel in point 6
24
+ * is independent of Connect. See `examples/add-to-cart.ts`.
24
25
  * 5. Anonymous tool-call tracking is opt-in via `registerTools`'s `tracking`
25
26
  * option (see `TrackingOptions`) and default-silent: with neither `apiKey`
26
27
  * nor `otel` set, no event is built at all — no identity resolution, no
@@ -37,7 +38,13 @@
37
38
  * both outputs — but for THIS channel only; the default-on telemetry channel of
38
39
  * point 6 has its own opt-out (`telemetry: false`) and `disabled` does not
39
40
  * narrow it. See `src/tracking.ts`, `src/transport.ts`, and
40
- * `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.
41
48
  * 6. Anonymous usage telemetry is a SECOND, independent channel: default-ON, so it
42
49
  * reports from every site the SDK runs on, not only from `apiKey` tenants. Three
43
50
  * events (`TelemetryEvent`), all `schema: 2` and joined on an in-memory
@@ -73,7 +80,8 @@
73
80
  * platform stays a config-only change to the entry module (point 4).
74
81
  */
75
82
  export { type AnyWebMCPTool, type ToolDefinition, type ToolIntent, type ToolSource, type WebMCPTool, defineTool, } from "./define.js";
76
- 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";
77
85
  export { type ModelContextLike, type RegisterToolOptions, type SpecTool, type ToolAnnotations, resolveModelContext, } from "./spec.js";
78
86
  export type { AgentRuntime, FormFactor, FrameContext, PageVisibility, ReferrerClass, SurfaceGlobal, SurfaceInfo, SurfaceProvenance, } from "./telemetry-context.js";
79
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.4.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
  },
@@ -1334,12 +1460,13 @@ function pruneByAllowlist(event, fields = TELEMETRY_FIELDS, toolFields = TELEMET
1334
1460
  return pruned;
1335
1461
  }
1336
1462
  var defaultSinks2 = {
1337
- sendTelemetry: (event) => sendTelemetry(event, globalThis, undefined, telemetryApiKey())
1463
+ sendTelemetry: (event) => sendTelemetry(event, globalThis, telemetryEndpoint(), telemetryApiKey())
1338
1464
  };
1339
- function batchTelemetrySinks(apiKey) {
1465
+ function batchTelemetrySinks(apiKey, endpoint) {
1340
1466
  const own = batchApiKey(apiKey);
1467
+ const ownEndpoint = telemetryEndpointFrom(endpoint);
1341
1468
  return {
1342
- sendTelemetry: (event) => sendTelemetry(event, globalThis, undefined, own ?? telemetryApiKey())
1469
+ sendTelemetry: (event) => sendTelemetry(event, globalThis, ownEndpoint ?? telemetryEndpoint(), own ?? telemetryApiKey())
1343
1470
  };
1344
1471
  }
1345
1472
  function batchApiKey(apiKey) {
@@ -1352,16 +1479,24 @@ function telemetryTenantScope(apiKey) {
1352
1479
  const resolved = resolveTelemetryKey(apiKey);
1353
1480
  return resolved === undefined ? "" : fnv1a(resolved);
1354
1481
  }
1355
- 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) {
1356
1483
  try {
1357
1484
  if (!telemetryEnabled())
1358
1485
  return;
1359
- 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)));
1360
1493
  } catch {}
1361
1494
  }
1362
1495
  var initCancelled = false;
1363
1496
  var initFlushed = false;
1497
+ var cancelInitFallback = () => {};
1364
1498
  var capturedApiKey;
1499
+ var capturedEndpoint;
1365
1500
  function captureTelemetryApiKey(apiKey) {
1366
1501
  if (typeof apiKey === "string" && apiKey.trim().length > 0)
1367
1502
  capturedApiKey = apiKey;
@@ -1369,17 +1504,44 @@ function captureTelemetryApiKey(apiKey) {
1369
1504
  function telemetryApiKey() {
1370
1505
  return capturedApiKey;
1371
1506
  }
1507
+ function telemetryEndpointFrom(endpoint) {
1508
+ if (typeof endpoint !== "string")
1509
+ return;
1510
+ return safe(() => {
1511
+ const url = new URL(endpoint);
1512
+ if (!["http:", "https:"].includes(url.protocol) || !url.pathname.endsWith("/v1/collect") || url.search !== "" || url.hash !== "") {
1513
+ return;
1514
+ }
1515
+ url.pathname = `${url.pathname.slice(0, -"/v1/collect".length)}/v1/telemetry`;
1516
+ return url.toString();
1517
+ });
1518
+ }
1519
+ function captureTelemetryEndpoint(endpoint) {
1520
+ const derived = telemetryEndpointFrom(endpoint);
1521
+ if (derived !== undefined)
1522
+ capturedEndpoint = derived;
1523
+ }
1524
+ function telemetryEndpoint() {
1525
+ return capturedEndpoint;
1526
+ }
1372
1527
  function cancelInitEvent() {
1373
1528
  initCancelled = true;
1529
+ cancelInitFallback();
1374
1530
  }
1375
1531
  function flushInitEvent(sinks) {
1376
1532
  if (initFlushed)
1377
1533
  return;
1378
1534
  initFlushed = true;
1535
+ cancelInitFallback();
1379
1536
  if (initCancelled)
1380
1537
  return;
1381
1538
  emitTelemetry(() => buildInitEvent(), sinks);
1382
1539
  }
1540
+ function deferInitEventForBatch(sinks) {
1541
+ if (initFlushed || initCancelled)
1542
+ return;
1543
+ afterDelay(() => flushInitEvent(sinks), 0);
1544
+ }
1383
1545
  function afterDelay(run, ms) {
1384
1546
  const schedule = safe(() => globalThis.setTimeout);
1385
1547
  if (typeof schedule !== "function")
@@ -1392,7 +1554,8 @@ function afterDelay(run, ms) {
1392
1554
  safe(() => clear.call(globalThis, handle));
1393
1555
  };
1394
1556
  }
1395
- afterDelay(() => flushInitEvent(), 0);
1557
+ var INIT_FALLBACK_MS = 1000;
1558
+ cancelInitFallback = afterDelay(() => flushInitEvent(), INIT_FALLBACK_MS);
1396
1559
 
1397
1560
  // src/register.ts
1398
1561
  function isContentResult(value) {
@@ -1414,15 +1577,18 @@ function clock() {
1414
1577
  const perf = safe(() => globalThis.performance);
1415
1578
  const now = safe(() => perf?.now);
1416
1579
  if (typeof now === "function") {
1417
- const read2 = () => safe(() => now.call(perf));
1418
- const start2 = read2();
1419
- if (start2 !== undefined)
1420
- return () => elapsedMs(read2(), start2);
1580
+ const read = () => safe(() => now.call(perf));
1581
+ const start = read();
1582
+ if (start !== undefined)
1583
+ return () => elapsedMs(read(), start);
1421
1584
  }
1422
1585
  const read = () => safe(() => Date.now());
1423
1586
  const start = read();
1424
1587
  return () => elapsedMs(read(), start);
1425
1588
  }
1589
+ function createCallTracker(tool, tracking) {
1590
+ return trackerFor({ ...tool, stableKey: tool.name }, tracking);
1591
+ }
1426
1592
  function trackerFor(tool, tracking) {
1427
1593
  if (!tracking)
1428
1594
  return;
@@ -1437,9 +1603,24 @@ function trackerFor(tool, tracking) {
1437
1603
  };
1438
1604
  return (eventName, data) => track(tracking, eventName, { ...shared, ...data });
1439
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
+ }
1440
1621
  function toSpecTool(tool, channels) {
1441
- const { tracking, telemetry, telemetrySinks, telemetryKey } = channels;
1442
- return {
1622
+ const { tracking, telemetry, telemetrySinks, telemetryKey, telemetryEndpoint } = channels;
1623
+ const spec = {
1443
1624
  name: tool.name,
1444
1625
  ...tool.title !== undefined ? { title: tool.title } : {},
1445
1626
  description: tool.description,
@@ -1451,7 +1632,7 @@ function toSpecTool(tool, channels) {
1451
1632
  return normalizeResult(await tool.execute(input));
1452
1633
  const callKey = telemetry ? resolveTelemetryKey(telemetryKey) : undefined;
1453
1634
  const sequence = telemetry ? nextCall(tool.stableKey, telemetryTenantScope(callKey)) : undefined;
1454
- const callSinks = sequence ? batchTelemetrySinks(callKey) : telemetrySinks;
1635
+ const callSinks = sequence ? batchTelemetrySinks(callKey, telemetryEndpoint) : telemetrySinks;
1455
1636
  const elapsed = clock();
1456
1637
  trackCall?.("tool_call_request", { input });
1457
1638
  try {
@@ -1475,6 +1656,7 @@ function toSpecTool(tool, channels) {
1475
1656
  }
1476
1657
  }
1477
1658
  };
1659
+ return markTracked(spec, tracking);
1478
1660
  }
1479
1661
  var REGISTRATION_TIMEOUT_MS = 2000;
1480
1662
  var PAGEHIDE = "pagehide";
@@ -1490,7 +1672,7 @@ function onPagehide(run) {
1490
1672
  safe(() => remove.call(g, PAGEHIDE, listener));
1491
1673
  };
1492
1674
  }
1493
- function watchRegistration(tools, tracking, sinks) {
1675
+ function watchRegistration(tools, tracking, sinks, navigation = false) {
1494
1676
  const entries = tools.map((tool) => ({ tool, outcome: "pending" }));
1495
1677
  const elapsed = clock();
1496
1678
  const { registrationIndex, trigger } = nextRegistration();
@@ -1505,7 +1687,7 @@ function watchRegistration(tools, tracking, sinks) {
1505
1687
  const settleMs = elapsed();
1506
1688
  emitTelemetry(() => buildToolRegistrationEvent({
1507
1689
  registrationIndex,
1508
- trigger,
1690
+ trigger: navigation ? "spa_navigation" : trigger,
1509
1691
  settleMs,
1510
1692
  tools: entries,
1511
1693
  tracking
@@ -1543,15 +1725,24 @@ function registerTools(tools, options = {}) {
1543
1725
  const tracking = safe(() => options.tracking);
1544
1726
  const telemetryLive = telemetryOption !== undefined && telemetryEnabled(telemetryOption.value);
1545
1727
  const apiKey = telemetryLive ? safe(() => tracking?.apiKey) : undefined;
1728
+ const endpoint = telemetryLive ? safe(() => tracking?.endpoint) : undefined;
1729
+ const telemetrySinks = telemetryLive ? batchTelemetrySinks(apiKey, endpoint) : undefined;
1546
1730
  const channels = {
1547
1731
  tracking,
1548
1732
  telemetry: telemetryLive,
1549
- ...telemetryLive ? { telemetrySinks: batchTelemetrySinks(apiKey), telemetryKey: apiKey } : {}
1733
+ ...telemetryLive ? {
1734
+ telemetrySinks,
1735
+ telemetryKey: apiKey,
1736
+ telemetryEndpoint: endpoint
1737
+ } : {}
1550
1738
  };
1551
1739
  if (telemetryOption?.value === false)
1552
1740
  cancelInitEvent();
1553
- if (telemetryLive)
1741
+ if (telemetrySinks) {
1554
1742
  captureTelemetryApiKey(apiKey);
1743
+ captureTelemetryEndpoint(endpoint);
1744
+ deferInitEventForBatch(telemetrySinks);
1745
+ }
1555
1746
  assertUniqueIdentities(tools);
1556
1747
  const controller = new AbortController;
1557
1748
  const external = options.signal;
@@ -1568,32 +1759,89 @@ function registerTools(tools, options = {}) {
1568
1759
  state,
1569
1760
  ...error !== undefined ? { error } : {}
1570
1761
  });
1571
- 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
+ };
1572
1779
  const settle = async (tool) => {
1573
1780
  if (!modelContext)
1574
1781
  return result(tool, "unsupported");
1575
1782
  if (controller.signal.aborted)
1576
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);
1577
1790
  try {
1578
1791
  await Promise.resolve(modelContext.registerTool(toSpecTool(tool, channels), {
1579
- signal: controller.signal
1792
+ get signal() {
1793
+ entry.removable = true;
1794
+ return entry.lifetime.signal;
1795
+ }
1580
1796
  }));
1581
- 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");
1582
1804
  } catch (error) {
1583
- if (controller.signal.aborted)
1805
+ if (active.get(tool) === entry)
1806
+ active.delete(tool);
1807
+ if (entry.lifetime.signal.aborted)
1584
1808
  return result(tool, "aborted");
1585
1809
  return result(tool, "failed", error);
1586
1810
  }
1587
1811
  };
1588
- const ready = Promise.all(tools.map(async (tool, index) => {
1589
- const settled = await settle(tool);
1590
- watch?.record(index, settled);
1591
- return settled;
1592
- }));
1593
- if (watch)
1594
- 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));
1595
1842
  return {
1596
1843
  ready,
1844
+ current,
1597
1845
  signal: controller.signal,
1598
1846
  unregister() {
1599
1847
  controller.abort();
@@ -1601,7 +1849,10 @@ function registerTools(tools, options = {}) {
1601
1849
  };
1602
1850
  }
1603
1851
  export {
1604
- resolveModelContext,
1852
+ createCallTracker,
1853
+ currentPageKey,
1854
+ defineTool,
1855
+ matchPage,
1605
1856
  registerTools,
1606
- defineTool
1857
+ resolveModelContext
1607
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()`.
@@ -44,10 +46,11 @@ export interface RegisterToolsOptions {
44
46
  *
45
47
  * The other two events are per-batch, so the option is read per call: a *later*
46
48
  * `registerTools` that omits it reports its own batch and calls. Cancelling
47
- * `sdk_init` also requires winning the race with its deferred flush a batch
48
- * registered at module scope does, one registered from a mount effect or a consent
49
- * callback may not. A site that wants the whole page silent regardless of where its
50
- * `registerTools` calls come from (generated code, a CDN snippet) should use a
49
+ * `sdk_init` also requires winning the race with its deferred flush. A batch
50
+ * registered at module scope or from an ordinary mount effect schedules the keyed
51
+ * batch flush; a later consent decision can still arrive after the bounded
52
+ * no-registration fallback. A site that wants the whole page silent regardless of
53
+ * when its `registerTools` calls run (generated code, a CDN snippet) should use a
51
54
  * page-level lever instead: `globalThis.__WEBMCP_TELEMETRY__ = false` — strictly
52
55
  * `false`, so a truthy `"0"` does not opt out — or Global Privacy Control
53
56
  * (`navigator.globalPrivacyControl === true`), both of which are read at emit time
@@ -63,18 +66,55 @@ export interface ToolRegistration {
63
66
  * cannot mask the rest.
64
67
  */
65
68
  ready: Promise<ToolRegistrationResult[]>;
69
+ /** Currently registered names, in declaration order; `ready` covers only the initial page. */
70
+ current(): string[];
66
71
  /** Unregister every tool in this batch. Idempotent. */
67
72
  unregister(): void;
68
73
  /** The signal carrying this registration's lifetime (aborted once unregistered). */
69
74
  signal: AbortSignal;
70
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;
71
111
  /**
72
112
  * Register a batch of `defineTool` tools on the page's WebMCP surface.
73
113
  *
74
- * Lifecycle: registration starts immediately; the tools stay live until
75
- * `unregister()` is called or the external `options.signal` aborts (both funnel
76
- * into one internal `AbortController`, the spec's only unregistration mechanism).
77
- * On browsers without a WebMCP surface this is a graceful no-op — generated code
78
- * 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.
79
119
  */
80
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;
@@ -360,7 +388,7 @@ export interface TelemetrySinks {
360
388
  * the raw batch option: a key resolved to a value pins it, closing the window in which
361
389
  * the module key changes between the claim and the emit.
362
390
  */
363
- export declare function batchTelemetrySinks(apiKey: unknown): TelemetrySinks;
391
+ export declare function batchTelemetrySinks(apiKey: unknown, endpoint?: unknown): TelemetrySinks;
364
392
  /**
365
393
  * The key a batch's beacons authenticate with, resolved now instead of at emit time:
366
394
  * its own when it supplied one, otherwise the page's.
@@ -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
@@ -438,6 +466,15 @@ export declare function emitTelemetry(build: () => TelemetryEvent, sinks?: Telem
438
466
  export declare function captureTelemetryApiKey(apiKey: unknown): void;
439
467
  /** The key {@link captureTelemetryApiKey} last recorded, if any. */
440
468
  export declare function telemetryApiKey(): string | undefined;
469
+ /**
470
+ * Record the telemetry endpoint for the deferred `sdk_init` flush. Like the captured
471
+ * key, a later usable value overwrites an earlier one while an absent or malformed
472
+ * value leaves the last usable value in place. URL parsing is best-effort because an
473
+ * observability option must never break the visitor's page.
474
+ */
475
+ export declare function captureTelemetryEndpoint(endpoint: unknown): void;
476
+ /** The endpoint {@link captureTelemetryEndpoint} last recorded, if any. */
477
+ export declare function telemetryEndpoint(): string | undefined;
441
478
  /**
442
479
  * Cancel the deferred `sdk_init` below. Called by `registerTools({ telemetry: false })`,
443
480
  * whose opt-out is page-level and not merely batch-level: the flush describes the
@@ -462,6 +499,12 @@ export declare function cancelInitEvent(): void;
462
499
  * Exported for tests; the scheduled timer below is the one that runs it on a real page.
463
500
  */
464
501
  export declare function flushInitEvent(sinks?: TelemetrySinks): void;
502
+ /**
503
+ * Give a telemetry-live registration batch the next task's `sdk_init` flush. This
504
+ * keeps the batch's key and endpoint together while leaving the rest of the current
505
+ * task available for a page-level opt-out or another synchronous registration.
506
+ */
507
+ export declare function deferInitEventForBatch(sinks: TelemetrySinks): void;
465
508
  /**
466
509
  * Run `run` after `ms`, returning a canceller. Guarded end to end: a runtime with no
467
510
  * timers (SSR) and a hostile `setTimeout` (an extension, a fake-timer harness) both
@@ -469,3 +512,5 @@ export declare function flushInitEvent(sinks?: TelemetrySinks): void;
469
512
  * `registerTools`. The canceller is safe to call when nothing was scheduled.
470
513
  */
471
514
  export declare function afterDelay(run: () => void, ms: number): () => void;
515
+ /** How long a page that never registers gets to expose a real batch configuration. */
516
+ export declare const INIT_FALLBACK_MS = 1000;
@@ -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.4.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",