@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 +52 -0
- package/dist/index.js +75 -5
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|