@rulvar/core 1.168.0 → 1.170.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/dist/index.d.ts CHANGED
@@ -10809,6 +10809,58 @@ interface McpConfig {
10809
10809
  approval?: boolean | Record<string, boolean>;
10810
10810
  /** Host-supplied risk labels for imported tools. */
10811
10811
  risk?: Record<string, ToolRisk>;
10812
+ /**
10813
+ * Cap on WIRE tools accepted from the tools/list sweep (RV1515),
10814
+ * checked after each page, PRE-filter: the sweep itself is the
10815
+ * resource being bounded, so allow/deny cannot admit past it. A
10816
+ * server that streams more refuses typed. Positive integer; absent =
10817
+ * unbounded (today's behavior).
10818
+ */
10819
+ maxTools?: number;
10820
+ /**
10821
+ * Per ADMITTED tool (allow/deny filter first): the UTF-8 byte length
10822
+ * of the serialized inputSchema plus outputSchema when present
10823
+ * (RV1515). An oversized tool refuses the resolution typed, naming
10824
+ * the tool and its measured bytes; deny the tool or raise the cap.
10825
+ * Positive integer; absent = unbounded.
10826
+ */
10827
+ maxSchemaBytes?: number;
10828
+ /**
10829
+ * Per-source latency bounds (RV1515). connectMs races the transport
10830
+ * handshake (on expiry the client, and for stdio its child, is
10831
+ * released and the refusal is typed). listMs and callMs ride the SDK
10832
+ * request timeout per tools/list page and per tools/call; without
10833
+ * them the SDK's own 60s default request timeout applies. A call
10834
+ * timeout surfaces as the tool's error result, never past policy.
10835
+ * Each a positive finite number of milliseconds.
10836
+ */
10837
+ timeouts?: {
10838
+ connectMs?: number;
10839
+ listMs?: number;
10840
+ callMs?: number;
10841
+ };
10842
+ /**
10843
+ * streamable-http only (RV1516): headers injected into EVERY wire
10844
+ * request through a wrapped fetch. The hook form is awaited before
10845
+ * each send, so it IS the refresh point: rotate a token in the hook
10846
+ * and the next request carries it, with no reconnect and no
10847
+ * library-invented 401 retry (transport failures surface exactly as
10848
+ * before; the engine's RetryPolicy owns retries).
10849
+ */
10850
+ http?: {
10851
+ headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
10852
+ };
10853
+ /**
10854
+ * What a listChanged notification means for THIS source (RV1516).
10855
+ * 'rekey' is the documented default: the session cache invalidates
10856
+ * and subsequently spawned agents import the changed list under a new
10857
+ * toolsetHash. 'refuse' fails closed instead: the notification
10858
+ * poisons the source, every later tools() call refuses typed, and
10859
+ * only close() (a deliberate host reset) clears it. In-flight spawn
10860
+ * snapshots are untouched either way. Composes with the toolset
10861
+ * attestation: refuse at the source vs refuse at the spawn.
10862
+ */
10863
+ drift?: "rekey" | "refuse";
10812
10864
  }
10813
10865
  /**
10814
10866
  * The ToolSource returned by {@link mcp}: the frozen ToolSource seam
package/dist/index.js CHANGED
@@ -3879,7 +3879,27 @@ function buildToolContext(seed) {
3879
3879
  *
3880
3880
  * Docs: https://docs.rulvar.com/guide/mcp.
3881
3881
  */
3882
+ function validateBounds(cfg) {
3883
+ const positiveInt = (key) => {
3884
+ const value = cfg[key];
3885
+ if (value !== void 0 && (!Number.isInteger(value) || value <= 0)) throw new ConfigError(`mcp: '${key}' must be a positive integer, got ${String(value)}`);
3886
+ };
3887
+ positiveInt("maxTools");
3888
+ positiveInt("maxSchemaBytes");
3889
+ for (const key of [
3890
+ "connectMs",
3891
+ "listMs",
3892
+ "callMs"
3893
+ ]) {
3894
+ const value = cfg.timeouts?.[key];
3895
+ if (value !== void 0 && (!Number.isFinite(value) || value <= 0)) throw new ConfigError(`mcp: 'timeouts.${key}' must be a positive finite number of milliseconds, got ${String(value)}`);
3896
+ }
3897
+ if (cfg.drift !== void 0 && cfg.drift !== "rekey" && cfg.drift !== "refuse") throw new ConfigError(`mcp: 'drift' must be 'rekey' or 'refuse', got '${String(cfg.drift)}'`);
3898
+ const headers = cfg.http?.headers;
3899
+ if (headers !== void 0 && typeof headers !== "function" && typeof headers !== "object") throw new ConfigError("mcp: 'http.headers' must be a record of header values or a (possibly async) function returning one");
3900
+ }
3882
3901
  function validateConfig(cfg) {
3902
+ validateBounds(cfg);
3883
3903
  const forbid = (key) => {
3884
3904
  if (cfg[key] !== void 0) throw new ConfigError(`mcp: '${key}' is not a config key of the '${cfg.transport}' transport (exactly the keys matching the chosen transport)`);
3885
3905
  };
@@ -3888,6 +3908,7 @@ function validateConfig(cfg) {
3888
3908
  if (cfg.command === void 0) throw new ConfigError("mcp: the stdio transport requires 'command'");
3889
3909
  forbid("url");
3890
3910
  forbid("server");
3911
+ forbid("http");
3891
3912
  return;
3892
3913
  case "streamable-http":
3893
3914
  if (cfg.url === void 0) throw new ConfigError("mcp: the streamable-http transport requires 'url'");
@@ -3900,6 +3921,7 @@ function validateConfig(cfg) {
3900
3921
  forbid("command");
3901
3922
  forbid("args");
3902
3923
  forbid("url");
3924
+ forbid("http");
3903
3925
  return;
3904
3926
  default: throw new ConfigError(`mcp: unknown transport '${String(cfg.transport)}'`);
3905
3927
  }
@@ -3920,6 +3942,23 @@ function mapContent(result) {
3920
3942
  text: block.text ?? ""
3921
3943
  } : block);
3922
3944
  }
3945
+ /**
3946
+ * Wraps fetch so EVERY wire request of the streamable-http transport
3947
+ * consults the declared headers before send (RV1516): the hook form is
3948
+ * the per-request refresh point for rotating tokens, so no reconnect
3949
+ * and no library-invented 401 retry exists or is needed.
3950
+ */
3951
+ function perRequestHeaders(headersOption) {
3952
+ return async (url, init) => {
3953
+ const extra = typeof headersOption === "function" ? await headersOption() : headersOption;
3954
+ const headers = new Headers(init?.headers);
3955
+ for (const [name, value] of Object.entries(extra ?? {})) headers.set(name, value);
3956
+ return fetch(url, {
3957
+ ...init,
3958
+ headers
3959
+ });
3960
+ };
3961
+ }
3923
3962
  function errorText(result) {
3924
3963
  const text = (result.content ?? []).filter((block) => block.type === "text").map((block) => block.text ?? "").join("");
3925
3964
  return text === "" ? "MCP tool reported an error" : text;
@@ -3942,12 +3981,13 @@ function mcp(cfg) {
3942
3981
  let cache;
3943
3982
  let generation = 0;
3944
3983
  let inFlight;
3984
+ let poisoned = false;
3945
3985
  const connect = async () => {
3946
3986
  const client = new Client({
3947
3987
  name: "rulvar",
3948
3988
  version: "1.0.0"
3949
3989
  });
3950
- try {
3990
+ const attach = async () => {
3951
3991
  if (cfg.transport === "stdio") {
3952
3992
  const transport = new StdioClientTransport({
3953
3993
  command: cfg.command ?? "",
@@ -3955,13 +3995,31 @@ function mcp(cfg) {
3955
3995
  });
3956
3996
  await client.connect(transport);
3957
3997
  } else if (cfg.transport === "streamable-http") {
3958
- const transport = new StreamableHTTPClientTransport(new URL(cfg.url ?? ""));
3998
+ const declaredHeaders = cfg.http?.headers;
3999
+ const transport = new StreamableHTTPClientTransport(new URL(cfg.url ?? ""), declaredHeaders === void 0 ? void 0 : { fetch: perRequestHeaders(declaredHeaders) });
3959
4000
  await client.connect(transport);
3960
4001
  } else {
3961
4002
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
3962
4003
  await cfg.server.connect(serverTransport);
3963
4004
  await client.connect(clientTransport);
3964
4005
  }
4006
+ };
4007
+ try {
4008
+ const budgetMs = cfg.timeouts?.connectMs;
4009
+ if (budgetMs === void 0) await attach();
4010
+ else {
4011
+ let timer;
4012
+ const expired = new Promise((_resolve, reject) => {
4013
+ timer = setTimeout(() => {
4014
+ reject(new ConfigError(`mcp: connect to '${sourceIdOf(cfg)}' timed out after ${budgetMs}ms`));
4015
+ }, budgetMs);
4016
+ });
4017
+ try {
4018
+ await Promise.race([attach(), expired]);
4019
+ } finally {
4020
+ clearTimeout(timer);
4021
+ }
4022
+ }
3965
4023
  } catch (error) {
3966
4024
  await client.close().catch(() => void 0);
3967
4025
  throw error;
@@ -3969,19 +4027,27 @@ function mcp(cfg) {
3969
4027
  client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
3970
4028
  generation += 1;
3971
4029
  cache = void 0;
4030
+ if (cfg.drift === "refuse") poisoned = true;
3972
4031
  });
3973
4032
  return client;
3974
4033
  };
3975
4034
  const listAll = async (client) => {
3976
4035
  const tools = [];
3977
4036
  let cursor;
4037
+ const listOptions = cfg.timeouts?.listMs === void 0 ? void 0 : { timeout: cfg.timeouts.listMs };
3978
4038
  do {
3979
- const page = await client.listTools(cursor === void 0 ? {} : { cursor });
4039
+ const page = await client.listTools(cursor === void 0 ? {} : { cursor }, listOptions);
3980
4040
  tools.push(...page.tools);
4041
+ if (cfg.maxTools !== void 0 && tools.length > cfg.maxTools) throw new ConfigError(`mcp: tools/list of '${sourceIdOf(cfg)}' returned at least ${tools.length} wire tools, over the declared maxTools ${cfg.maxTools}; raise the cap or trim the server`);
3981
4042
  cursor = page.nextCursor;
3982
4043
  } while (cursor !== void 0 && cursor !== "");
3983
4044
  return tools;
3984
4045
  };
4046
+ const enforceSchemaBytes = (wire) => {
4047
+ if (cfg.maxSchemaBytes === void 0) return;
4048
+ const bytes = Buffer.byteLength(JSON.stringify(wire.inputSchema) + (wire.outputSchema === void 0 ? "" : JSON.stringify(wire.outputSchema)), "utf8");
4049
+ if (bytes > cfg.maxSchemaBytes) throw new ConfigError(`mcp: tool '${wire.name}' declares ${bytes} bytes of schema, over the declared maxSchemaBytes ${cfg.maxSchemaBytes}; deny the tool or raise the cap`);
4050
+ };
3985
4051
  const needsApprovalFor = (originalName) => {
3986
4052
  if (cfg.approval === void 0) return false;
3987
4053
  if (typeof cfg.approval === "boolean") return cfg.approval;
@@ -4001,7 +4067,7 @@ function mcp(cfg) {
4001
4067
  const result = await client.callTool({
4002
4068
  name: wire.name,
4003
4069
  arguments: input ?? {}
4004
- });
4070
+ }, void 0, cfg.timeouts?.callMs === void 0 ? void 0 : { timeout: cfg.timeouts.callMs });
4005
4071
  if (result.isError === true) throw new Error(errorText(result));
4006
4072
  if (result.structuredContent !== void 0) {
4007
4073
  if (wire.outputSchema !== void 0) {
@@ -4017,6 +4083,7 @@ function mcp(cfg) {
4017
4083
  return {
4018
4084
  id: sourceIdOf(cfg),
4019
4085
  tools: async () => {
4086
+ if (poisoned) throw new ConfigError(`mcp: the tool list of '${sourceIdOf(cfg)}' changed after import (listChanged) and drift policy 'refuse' holds the source closed; close() and re-create the source (and re-record any toolset attestation) to import the changed list deliberately`);
4020
4087
  if (cache !== void 0) return cache;
4021
4088
  if (inFlight !== void 0) return inFlight;
4022
4089
  const fetch = (async () => {
@@ -4026,7 +4093,9 @@ function mcp(cfg) {
4026
4093
  const wireTools = await listAll(client);
4027
4094
  const denySet = new Set(cfg.deny ?? []);
4028
4095
  const allowSet = cfg.allow === void 0 ? void 0 : new Set(cfg.allow);
4029
- const defs = wireTools.filter((wire) => !denySet.has(wire.name) && (allowSet === void 0 || allowSet.has(wire.name))).map((wire) => toDef(client, wire));
4096
+ const admitted = wireTools.filter((wire) => !denySet.has(wire.name) && (allowSet === void 0 || allowSet.has(wire.name)));
4097
+ for (const wire of admitted) enforceSchemaBytes(wire);
4098
+ const defs = admitted.map((wire) => toDef(client, wire));
4030
4099
  if (generation === fetchedAt) cache = defs;
4031
4100
  return defs;
4032
4101
  })();
@@ -4041,6 +4110,7 @@ function mcp(cfg) {
4041
4110
  const pending = clientPromise;
4042
4111
  clientPromise = void 0;
4043
4112
  cache = void 0;
4113
+ poisoned = false;
4044
4114
  if (pending === void 0) return;
4045
4115
  let client;
4046
4116
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/core",
3
- "version": "1.168.0",
3
+ "version": "1.170.0",
4
4
  "description": "Rulvar core: L0 contracts, journal kernel, ctx primitives, agent runtime, model router, tool system, dynamic orchestrator, InMemory and JSONL stores, event stream.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",