@vesta-analytics/sdk 0.1.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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Redaction engine. Pure — no framework or OTel imports.
3
+ *
4
+ * Precedence: field rules > subject rules (tool/prompt/resource) > method
5
+ * rules > default + denylist. Within field rules: exact path beats glob;
6
+ * among globs, the longer pattern wins. The shipped denylist behaves as
7
+ * implicit lowest-precedence field redacts, only relevant when
8
+ * default === "capture". This is a 1:1 port of the Python SDK's `redaction.py`.
9
+ */
10
+ import { createHash } from 'node:crypto';
11
+ export const REDACTED = '<REDACTED>';
12
+ export const DEFAULT_DENYLIST = new Set([
13
+ 'email', 'password', 'ssn', 'phone', 'address', 'credit_card',
14
+ 'card_number', 'cvv', 'dob', 'date_of_birth', 'tax_id', 'passport',
15
+ 'api_key', 'auth_token', 'bearer', 'secret', 'private_key',
16
+ ]);
17
+ function parseAction(spec) {
18
+ if (spec.startsWith('truncate(') && spec.endsWith(')')) {
19
+ return { name: 'truncate', n: parseInt(spec.slice('truncate('.length, -1), 10) };
20
+ }
21
+ if (spec === 'capture' || spec === 'redact' || spec === 'hash') {
22
+ return { name: spec };
23
+ }
24
+ throw new Error(`unknown action: ${JSON.stringify(spec)}`);
25
+ }
26
+ /**
27
+ * Render a scalar the way Python's `repr()` does, so the `hash` action
28
+ * produces the same digest across the Python and TypeScript SDKs for the
29
+ * common scalar cases (string, integer, float-with-fraction, bool, null).
30
+ * Exotic types are a documented divergence — `_leaves` only ever hands us
31
+ * scalars, so this covers redaction in practice.
32
+ */
33
+ export function pyRepr(value) {
34
+ if (value === null || value === undefined)
35
+ return 'None';
36
+ if (typeof value === 'boolean')
37
+ return value ? 'True' : 'False';
38
+ if (typeof value === 'number') {
39
+ if (Number.isInteger(value))
40
+ return String(value);
41
+ return String(value);
42
+ }
43
+ if (typeof value === 'string') {
44
+ const hasSingle = value.includes("'");
45
+ const hasDouble = value.includes('"');
46
+ // Python prefers single quotes; switches to double quotes when the string
47
+ // contains a single quote but no double quote.
48
+ const quote = hasSingle && !hasDouble ? '"' : "'";
49
+ let out = '';
50
+ for (const ch of value) {
51
+ if (ch === '\\')
52
+ out += '\\\\';
53
+ else if (ch === '\n')
54
+ out += '\\n';
55
+ else if (ch === '\r')
56
+ out += '\\r';
57
+ else if (ch === '\t')
58
+ out += '\\t';
59
+ else if (ch === quote)
60
+ out += '\\' + quote;
61
+ else {
62
+ const code = ch.codePointAt(0);
63
+ if (code < 0x20 || code === 0x7f) {
64
+ out += '\\x' + code.toString(16).padStart(2, '0');
65
+ }
66
+ else {
67
+ out += ch;
68
+ }
69
+ }
70
+ }
71
+ return quote + out + quote;
72
+ }
73
+ return String(value);
74
+ }
75
+ /** Return [newValue, wasRedacted]. 'capture' is the only no-op. */
76
+ function applyAction(action, value) {
77
+ switch (action.name) {
78
+ case 'capture':
79
+ return [value, false];
80
+ case 'redact':
81
+ return [REDACTED, true];
82
+ case 'hash': {
83
+ const digest = createHash('sha256').update(pyRepr(value), 'utf8').digest('hex');
84
+ return [digest.slice(0, 16), true];
85
+ }
86
+ case 'truncate': {
87
+ if (typeof value !== 'string')
88
+ return [REDACTED, true];
89
+ const n = action.n ?? 0;
90
+ const tooLong = value.length > n;
91
+ return [tooLong ? value.slice(0, n) + '…' : value, tooLong];
92
+ }
93
+ default:
94
+ return [REDACTED, true];
95
+ }
96
+ }
97
+ function escapeRegex(ch) {
98
+ return ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
99
+ }
100
+ function globToRegex(pattern) {
101
+ const out = [];
102
+ let i = 0;
103
+ while (i < pattern.length) {
104
+ if (pattern.startsWith('**', i)) {
105
+ // '**.email' matches both 'email' (top-level) and 'user.email'.
106
+ if (i + 2 < pattern.length && pattern[i + 2] === '.') {
107
+ out.push('(.*\\.)?');
108
+ i += 3;
109
+ }
110
+ else {
111
+ out.push('.*');
112
+ i += 2;
113
+ }
114
+ }
115
+ else if (pattern[i] === '*') {
116
+ out.push('[^.]*');
117
+ i += 1;
118
+ }
119
+ else {
120
+ out.push(escapeRegex(pattern[i]));
121
+ i += 1;
122
+ }
123
+ }
124
+ return new RegExp('^' + out.join('') + '$', 'i');
125
+ }
126
+ // Maps each subject-level rule type to the MCP method it is scoped to. A
127
+ // Tool() rule means "tools/call only" and never matches a prompts/get or
128
+ // resources/read call.
129
+ const SUBJECT_LEVEL_METHODS = {
130
+ tool: 'tools/call',
131
+ prompt: 'prompts/get',
132
+ resource: 'resources/read',
133
+ };
134
+ class Builder {
135
+ level;
136
+ pattern;
137
+ constructor(level, pattern) {
138
+ this.level = level;
139
+ this.pattern = pattern;
140
+ }
141
+ mk(action, predicate = null) {
142
+ return {
143
+ level: this.level,
144
+ pattern: this.pattern,
145
+ action,
146
+ predicate,
147
+ rx: globToRegex(this.pattern),
148
+ specificity: [this.pattern.includes('*') ? 0 : 1, this.pattern.length],
149
+ };
150
+ }
151
+ capture() {
152
+ return this.mk({ name: 'capture' });
153
+ }
154
+ redact() {
155
+ return this.mk({ name: 'redact' });
156
+ }
157
+ hash() {
158
+ return this.mk({ name: 'hash' });
159
+ }
160
+ truncate(n) {
161
+ return this.mk({ name: 'truncate', n });
162
+ }
163
+ check(predicate) {
164
+ return this.mk(null, predicate);
165
+ }
166
+ }
167
+ export function Field(pattern) {
168
+ return new Builder('field', pattern);
169
+ }
170
+ /** Match by tool name. Implicitly scoped to method=tools/call. */
171
+ export function Tool(pattern) {
172
+ return new Builder('tool', pattern);
173
+ }
174
+ /** Match by prompt name. Scoped to method=prompts/get. */
175
+ export function Prompt(pattern) {
176
+ return new Builder('prompt', pattern);
177
+ }
178
+ /** Match by resource URI glob. Scoped to method=resources/read. */
179
+ export function Resource(pattern) {
180
+ return new Builder('resource', pattern);
181
+ }
182
+ /** Match by MCP method name (`tools/list`, `prompts/*`, …). */
183
+ export function Method(pattern) {
184
+ return new Builder('method', pattern);
185
+ }
186
+ export class RedactionConfig {
187
+ default;
188
+ rules;
189
+ defaultAction;
190
+ fieldRules;
191
+ subjectRules;
192
+ methodRules;
193
+ constructor(init = {}) {
194
+ this.default = init.default ?? 'redact';
195
+ this.rules = init.rules ?? [];
196
+ this.defaultAction = parseAction(this.default);
197
+ this.fieldRules = this.rules
198
+ .filter((r) => r.level === 'field')
199
+ .sort((a, b) => {
200
+ // higher specificity first
201
+ if (a.specificity[0] !== b.specificity[0])
202
+ return b.specificity[0] - a.specificity[0];
203
+ return b.specificity[1] - a.specificity[1];
204
+ });
205
+ this.subjectRules = {
206
+ tool: this.rules.filter((r) => r.level === 'tool'),
207
+ prompt: this.rules.filter((r) => r.level === 'prompt'),
208
+ resource: this.rules.filter((r) => r.level === 'resource'),
209
+ };
210
+ this.methodRules = this.rules.filter((r) => r.level === 'method');
211
+ }
212
+ }
213
+ function isPlainObject(x) {
214
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
215
+ }
216
+ /** Yield a leaf for every scalar in the tree. `coords` is the exact write-back
217
+ * navigation path; `dotted` is the index-agnostic path used for rule matching
218
+ * (list membership collapses to `[*]`). */
219
+ function* leaves(node, coords = [], dotted = '') {
220
+ if (isPlainObject(node)) {
221
+ for (const [k, v] of Object.entries(node)) {
222
+ const newDotted = dotted ? `${dotted}.${k}` : String(k);
223
+ yield* leaves(v, [...coords, k], newDotted);
224
+ }
225
+ }
226
+ else if (Array.isArray(node)) {
227
+ for (let idx = 0; idx < node.length; idx++) {
228
+ yield* leaves(node[idx], [...coords, idx], `${dotted}[*]`);
229
+ }
230
+ }
231
+ else {
232
+ yield { coords, dotted, value: node };
233
+ }
234
+ }
235
+ function resolve(path, value, toolName, method, cfg) {
236
+ const leaf = path.split('.').pop().split('[')[0].toLowerCase();
237
+ // 1. Field rules (path-based, any method).
238
+ for (const r of cfg.fieldRules) {
239
+ if (r.rx.test(path)) {
240
+ if (r.predicate !== null)
241
+ return parseAction(r.predicate(path, value));
242
+ if (r.action !== null)
243
+ return r.action;
244
+ return cfg.defaultAction;
245
+ }
246
+ }
247
+ // 2. Subject-level rules (tool/prompt/resource), scoped to their method.
248
+ for (const [level, scopedMethod] of Object.entries(SUBJECT_LEVEL_METHODS)) {
249
+ if (method !== scopedMethod)
250
+ continue;
251
+ for (const r of cfg.subjectRules[level]) {
252
+ if (r.rx.test(toolName)) {
253
+ if (r.action !== null)
254
+ return r.action;
255
+ return cfg.defaultAction;
256
+ }
257
+ }
258
+ }
259
+ // 3. Method-level rules (match on method itself, any subject).
260
+ for (const r of cfg.methodRules) {
261
+ if (r.rx.test(method)) {
262
+ if (r.action !== null)
263
+ return r.action;
264
+ return cfg.defaultAction;
265
+ }
266
+ }
267
+ // 4. Capture-default + denylist fallback.
268
+ if (cfg.defaultAction.name === 'capture' && DEFAULT_DENYLIST.has(leaf)) {
269
+ return { name: 'redact' };
270
+ }
271
+ return cfg.defaultAction;
272
+ }
273
+ function setAt(root, coords, newValue) {
274
+ let node = root;
275
+ for (let i = 0; i < coords.length - 1; i++) {
276
+ node = node[coords[i]];
277
+ }
278
+ node[coords[coords.length - 1]] = newValue;
279
+ }
280
+ export function redactPayload(payload, opts) {
281
+ const { toolName, config } = opts;
282
+ const method = opts.method ?? 'tools/call';
283
+ if (payload === null || payload === undefined) {
284
+ return { scrubbed: payload === undefined ? null : null, count: 0, paths: [] };
285
+ }
286
+ if (!isPlainObject(payload) && !Array.isArray(payload)) {
287
+ const action = resolve('', payload, toolName, method, config);
288
+ const [next, hit] = applyAction(action, payload);
289
+ return { scrubbed: next, count: hit ? 1 : 0, paths: hit ? [''] : [] };
290
+ }
291
+ const scrubbed = structuredClone(payload);
292
+ let count = 0;
293
+ const paths = [];
294
+ for (const { coords, dotted, value } of leaves(payload)) {
295
+ const action = resolve(dotted, value, toolName, method, config);
296
+ const [next, hit] = applyAction(action, value);
297
+ if (hit) {
298
+ count += 1;
299
+ paths.push(dotted);
300
+ setAt(scrubbed, coords, next);
301
+ }
302
+ }
303
+ return { scrubbed, count, paths };
304
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@vesta-analytics/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Vesta customer SDK — instrument an MCP server with one call.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "author": "Vesta <hello@vesta-analytics.ai>",
8
+ "homepage": "https://vesta-analytics.ai",
9
+ "keywords": [
10
+ "mcp",
11
+ "model-context-protocol",
12
+ "opentelemetry",
13
+ "observability",
14
+ "analytics"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.build.json",
35
+ "prepublishOnly": "npm run build",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest",
38
+ "typecheck": "tsc --noEmit"
39
+ },
40
+ "dependencies": {
41
+ "@opentelemetry/api": "^1.9.0",
42
+ "@opentelemetry/exporter-trace-otlp-proto": "^0.57.0",
43
+ "@opentelemetry/resources": "^1.30.0",
44
+ "@opentelemetry/sdk-trace-base": "^1.30.0",
45
+ "@opentelemetry/semantic-conventions": "^1.30.0"
46
+ },
47
+ "peerDependencies": {
48
+ "@modelcontextprotocol/sdk": ">=1.0.0",
49
+ "fastmcp": ">=1.0.0"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@modelcontextprotocol/sdk": {
53
+ "optional": true
54
+ },
55
+ "fastmcp": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "devDependencies": {
60
+ "@modelcontextprotocol/sdk": "^1.12.0",
61
+ "@types/node": "^22.10.0",
62
+ "fastmcp": "^3.0.0",
63
+ "typescript": "^5.7.0",
64
+ "vitest": "^2.1.0"
65
+ }
66
+ }