@beeeeen/mcp-probe 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,17 @@
1
+ import type { Check } from '../types.js';
2
+ export declare const unknownToolCheck: Check;
3
+ /**
4
+ * Invalid-argument probes are safe to run unattended: a correct server rejects
5
+ * them at validation, before any side effect. That is precisely what is being
6
+ * measured, and it is why this runs by default while valid calls do not.
7
+ */
8
+ export declare const invalidArgsCheck: Check;
9
+ /** Garbage on the wire must not be fatal; a public server sees it constantly. */
10
+ export declare const malformedInputCheck: Check;
11
+ /**
12
+ * Tool results land in the context window verbatim. A tool that returns a
13
+ * whole file or an unpaginated list can consume the entire budget in one call.
14
+ * Only measured for tools actually invoked, so it is opt-in with the rest.
15
+ */
16
+ export declare const responseSizeCheck: Check;
17
+ export declare const robustnessChecks: Check[];
@@ -0,0 +1,240 @@
1
+ import { result, preview, isPlainObject, estimateTokens } from './util.js';
2
+ const SPEC = 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools';
3
+ /**
4
+ * A tools/call that failed is reported one of two legal ways: a JSON-RPC error,
5
+ * or a result carrying `isError: true`. Both are fine; neither is a crash.
6
+ */
7
+ function rejectedCleanly(res) {
8
+ if (res.error)
9
+ return true;
10
+ return isPlainObject(res.result) && res.result['isError'] === true;
11
+ }
12
+ export const unknownToolCheck = {
13
+ id: 'robustness.unknown_tool',
14
+ title: 'Calling a tool that does not exist is rejected',
15
+ severity: 'error',
16
+ spec: SPEC,
17
+ async run(ctx) {
18
+ const name = '__mcp_probe_no_such_tool__';
19
+ try {
20
+ const res = await ctx.client.call('tools/call', { name, arguments: {} }, Math.min(ctx.options.timeoutMs, 8000));
21
+ if (rejectedCleanly(res)) {
22
+ return [result('robustness.unknown_tool', 'Calling a tool that does not exist is rejected', 'pass', 'error')];
23
+ }
24
+ return [
25
+ result('robustness.unknown_tool', 'Calling a tool that does not exist is rejected', 'fail', 'error', {
26
+ message: 'An unknown tool name returned a success result.',
27
+ detail: `Result: ${preview(res.result)}\nModels hallucinate tool names. A server that answers them feeds the hallucination back as fact.`,
28
+ spec: SPEC,
29
+ }),
30
+ ];
31
+ }
32
+ catch (e) {
33
+ return [
34
+ result('robustness.unknown_tool', 'Calling a tool that does not exist is rejected', 'fail', 'error', {
35
+ message: `Server stopped responding: ${e.message}`,
36
+ detail: 'An unknown tool name must not be fatal. Models routinely invent names, and this takes the server down every time.',
37
+ spec: SPEC,
38
+ }),
39
+ ];
40
+ }
41
+ },
42
+ };
43
+ /** Build arguments that are the wrong type for every declared property. */
44
+ function wrongTypedArgs(schema) {
45
+ if (!isPlainObject(schema) || !isPlainObject(schema.properties))
46
+ return null;
47
+ const args = {};
48
+ for (const [key, prop] of Object.entries(schema.properties)) {
49
+ const t = Array.isArray(prop.type) ? prop.type?.[0] : prop.type;
50
+ // Deliberately mismatched: a string where a number is wanted, and so on.
51
+ args[key] = t === 'string' ? 12345 : t === 'number' || t === 'integer' ? 'not-a-number' : t === 'boolean' ? 'maybe' : t === 'array' ? {} : [];
52
+ }
53
+ return Object.keys(args).length > 0 ? args : null;
54
+ }
55
+ /**
56
+ * Invalid-argument probes are safe to run unattended: a correct server rejects
57
+ * them at validation, before any side effect. That is precisely what is being
58
+ * measured, and it is why this runs by default while valid calls do not.
59
+ */
60
+ export const invalidArgsCheck = {
61
+ id: 'robustness.invalid_args',
62
+ title: 'Invalid arguments are rejected, not executed',
63
+ severity: 'error',
64
+ spec: SPEC,
65
+ async run(ctx) {
66
+ const out = [];
67
+ const candidates = ctx.tools.filter((t) => t?.name && isPlainObject(t.inputSchema)).slice(0, 12);
68
+ if (candidates.length === 0) {
69
+ return [
70
+ result('robustness.invalid_args', 'Invalid arguments are rejected', 'skip', 'error', {
71
+ message: 'No tools with an inputSchema to probe.',
72
+ }),
73
+ ];
74
+ }
75
+ for (const tool of candidates) {
76
+ const name = tool.name;
77
+ const required = Array.isArray(tool.inputSchema?.required) ? tool.inputSchema.required : [];
78
+ // 1. Omit every required argument.
79
+ if (required.length > 0) {
80
+ try {
81
+ const res = await ctx.client.call('tools/call', { name, arguments: {} }, Math.min(ctx.options.timeoutMs, 8000));
82
+ if (!rejectedCleanly(res)) {
83
+ out.push(result('robustness.invalid_args.missing_required', 'Missing required arguments are rejected', 'fail', 'error', {
84
+ target: name,
85
+ message: `Ran with none of its ${required.length} required argument${required.length === 1 ? '' : 's'} and reported success.`,
86
+ detail: `Required: ${required.join(', ')}\nResult: ${preview(res.result)}\nThe tool either silently used defaults or acted on undefined -- both produce wrong answers the model will trust.`,
87
+ spec: SPEC,
88
+ }));
89
+ }
90
+ }
91
+ catch (e) {
92
+ out.push(result('robustness.invalid_args.missing_required', 'Missing required arguments are rejected', 'fail', 'error', {
93
+ target: name,
94
+ message: `Server stopped responding when required arguments were omitted: ${e.message}`,
95
+ detail: 'This is an unguarded property access on the argument object. Validate before you dereference.',
96
+ spec: SPEC,
97
+ }));
98
+ }
99
+ }
100
+ // 2. Send every argument with the wrong type.
101
+ const bad = wrongTypedArgs(tool.inputSchema);
102
+ if (bad) {
103
+ try {
104
+ const res = await ctx.client.call('tools/call', { name, arguments: bad }, Math.min(ctx.options.timeoutMs, 8000));
105
+ if (!rejectedCleanly(res)) {
106
+ out.push(result('robustness.invalid_args.wrong_types', 'Wrongly typed arguments are rejected', 'warn', 'warn', {
107
+ target: name,
108
+ message: 'Accepted arguments whose types contradict its own schema.',
109
+ detail: `Sent: ${preview(bad, 200)}\nResult: ${preview(res.result, 200)}\nModels do emit the wrong type. Coercing silently turns a type error into a wrong result.`,
110
+ spec: SPEC,
111
+ }));
112
+ }
113
+ }
114
+ catch (e) {
115
+ out.push(result('robustness.invalid_args.wrong_types', 'Wrongly typed arguments are rejected', 'fail', 'error', {
116
+ target: name,
117
+ message: `Server stopped responding on wrongly typed arguments: ${e.message}`,
118
+ spec: SPEC,
119
+ }));
120
+ }
121
+ }
122
+ }
123
+ if (out.length === 0) {
124
+ out.push(result('robustness.invalid_args', 'Invalid arguments are rejected, not executed', 'pass', 'error', {
125
+ message: `${candidates.length} tool${candidates.length === 1 ? '' : 's'} rejected bad input cleanly.`,
126
+ }));
127
+ }
128
+ return out;
129
+ },
130
+ };
131
+ /** Garbage on the wire must not be fatal; a public server sees it constantly. */
132
+ export const malformedInputCheck = {
133
+ id: 'robustness.malformed_input',
134
+ title: 'Survives malformed JSON-RPC',
135
+ severity: 'error',
136
+ spec: 'https://www.jsonrpc.org/specification#error_object',
137
+ async run(ctx) {
138
+ const t = ctx.client.transport;
139
+ if (t.kind !== 'stdio') {
140
+ return [result('robustness.malformed_input', 'Survives malformed JSON-RPC', 'skip', 'error', { message: 'stdio only.' })];
141
+ }
142
+ t.writeRaw('this is not json at all\n');
143
+ t.writeRaw('{"jsonrpc":"2.0","id": \n');
144
+ t.writeRaw('{"jsonrpc":"2.0","method":123,"id":"x"}\n');
145
+ // Give the server a moment to mishandle it, then check it is still there.
146
+ await new Promise((r) => setTimeout(r, 300));
147
+ if (!t.isAlive()) {
148
+ const info = t.exitInfo();
149
+ return [
150
+ result('robustness.malformed_input', 'Survives malformed JSON-RPC', 'fail', 'error', {
151
+ message: `Server exited (code ${info?.code ?? 'null'}) after receiving malformed input.`,
152
+ detail: `Last stderr:\n${t.stderr.slice(-8).join('\n') || '(none)'}\n\nA parse failure must produce a -32700 response, not terminate the process.`,
153
+ }),
154
+ ];
155
+ }
156
+ try {
157
+ const res = await ctx.client.call('tools/list', {}, Math.min(ctx.options.timeoutMs, 5000));
158
+ if (res.error) {
159
+ return [
160
+ result('robustness.malformed_input', 'Survives malformed JSON-RPC', 'warn', 'warn', {
161
+ message: `Still running, but tools/list now errors: ${res.error.code} ${res.error.message}`,
162
+ detail: 'The garbage left the read loop in a bad state. Usually a buffer that was never reset after a parse failure.',
163
+ }),
164
+ ];
165
+ }
166
+ return [
167
+ result('robustness.malformed_input', 'Survives malformed JSON-RPC', 'pass', 'error', {
168
+ message: 'Still serving requests after three malformed frames.',
169
+ }),
170
+ ];
171
+ }
172
+ catch (e) {
173
+ return [
174
+ result('robustness.malformed_input', 'Survives malformed JSON-RPC', 'fail', 'error', {
175
+ message: `Process alive but unresponsive after malformed input: ${e.message}`,
176
+ detail: 'The read loop is wedged -- typically a partial frame left in the buffer that every later read tries to parse again.',
177
+ }),
178
+ ];
179
+ }
180
+ },
181
+ };
182
+ /**
183
+ * Tool results land in the context window verbatim. A tool that returns a
184
+ * whole file or an unpaginated list can consume the entire budget in one call.
185
+ * Only measured for tools actually invoked, so it is opt-in with the rest.
186
+ */
187
+ export const responseSizeCheck = {
188
+ id: 'robustness.response_size',
189
+ title: 'Tool results fit in a context window',
190
+ severity: 'warn',
191
+ spec: SPEC,
192
+ async run(ctx) {
193
+ const allowed = new Set(ctx.options.safeTools);
194
+ const targets = ctx.options.callTools
195
+ ? ctx.tools.filter((t) => t?.name)
196
+ : ctx.tools.filter((t) => t?.name && allowed.has(t.name));
197
+ if (targets.length === 0) {
198
+ return [
199
+ result('robustness.response_size', 'Tool results fit in a context window', 'skip', 'warn', {
200
+ message: 'No tools were invoked. Pass --call-tools, or --safe-tool <name>, to measure real responses.',
201
+ }),
202
+ ];
203
+ }
204
+ const out = [];
205
+ for (const tool of targets.slice(0, 12)) {
206
+ try {
207
+ const res = await ctx.client.call('tools/call', { name: tool.name, arguments: {} }, ctx.options.timeoutMs);
208
+ if (res.error)
209
+ continue;
210
+ const tokens = estimateTokens(JSON.stringify(res.result ?? {}));
211
+ if (tokens > 25_000) {
212
+ out.push(result('robustness.response_size.huge', 'Tool result fits in a context window', 'fail', 'error', {
213
+ target: tool.name,
214
+ message: `Returned ~${tokens.toLocaleString('en-US')} tokens in a single call.`,
215
+ detail: 'This evicts the conversation it was meant to inform. Paginate, or return a summary with a follow-up tool to drill in.',
216
+ spec: SPEC,
217
+ }));
218
+ }
219
+ else if (tokens > 8_000) {
220
+ out.push(result('robustness.response_size.large', 'Tool result fits in a context window', 'warn', 'warn', {
221
+ target: tool.name,
222
+ message: `Returned ~${tokens.toLocaleString('en-US')} tokens.`,
223
+ detail: 'Large enough to crowd out earlier turns. Consider a limit/cursor argument.',
224
+ spec: SPEC,
225
+ }));
226
+ }
227
+ }
228
+ catch {
229
+ // Timeouts and transport faults are already covered by other checks.
230
+ }
231
+ }
232
+ if (out.length === 0) {
233
+ out.push(result('robustness.response_size', 'Tool results fit in a context window', 'pass', 'warn', {
234
+ message: `${Math.min(targets.length, 12)} tool response${targets.length === 1 ? '' : 's'} within budget.`,
235
+ }));
236
+ }
237
+ return out;
238
+ },
239
+ };
240
+ export const robustnessChecks = [unknownToolCheck, invalidArgsCheck, malformedInputCheck, responseSizeCheck];
@@ -0,0 +1,12 @@
1
+ import type { Check } from '../types.js';
2
+ export declare const toolNameCheck: Check;
3
+ export declare const toolDescriptionCheck: Check;
4
+ export declare const inputSchemaCheck: Check;
5
+ /**
6
+ * Every tool definition is re-sent on every single request for the whole
7
+ * session. A bloated tool list is a permanent tax on the context window and
8
+ * shows up directly on the user's bill -- but nothing in the toolchain
9
+ * measures it, so it grows unnoticed.
10
+ */
11
+ export declare const contextWeightCheck: Check;
12
+ export declare const schemaChecks: Check[];
@@ -0,0 +1,224 @@
1
+ import { result, preview, isPlainObject, estimateTokens } from './util.js';
2
+ const SPEC = 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools';
3
+ /** What hosts accept today. Anything else gets rejected at registration time. */
4
+ const NAME_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/;
5
+ /** Descriptions shorter than this cannot disambiguate a tool from its siblings. */
6
+ const MIN_DESCRIPTION_CHARS = 20;
7
+ const PLACEHOLDER_DESCRIPTIONS = /^(todo|tbd|fixme|xxx|n\/?a|description|test|foo|bar|\.+)$/i;
8
+ export const toolNameCheck = {
9
+ id: 'schema.tool_name',
10
+ title: 'Tool names are valid and unique',
11
+ severity: 'error',
12
+ spec: SPEC,
13
+ run(ctx) {
14
+ const out = [];
15
+ const seen = new Map();
16
+ for (const tool of ctx.tools) {
17
+ const name = tool?.name;
18
+ if (typeof name !== 'string' || !name) {
19
+ out.push(result('schema.tool_name.missing', 'Tool has a name', 'fail', 'error', {
20
+ message: 'A tool in tools/list has no `name`.',
21
+ detail: preview(tool),
22
+ spec: SPEC,
23
+ }));
24
+ continue;
25
+ }
26
+ seen.set(name, (seen.get(name) ?? 0) + 1);
27
+ if (!NAME_PATTERN.test(name)) {
28
+ out.push(result('schema.tool_name.invalid', 'Tool name is host-compatible', 'fail', 'error', {
29
+ target: name,
30
+ message: `"${name}" contains characters hosts reject.`,
31
+ detail: 'Allowed: letters, digits, underscore, hyphen; 1-128 chars. Spaces and dots break tool routing in several hosts.',
32
+ spec: SPEC,
33
+ }));
34
+ }
35
+ }
36
+ for (const [name, count] of seen) {
37
+ if (count > 1) {
38
+ out.push(result('schema.tool_name.duplicate', 'Tool names are unique', 'fail', 'error', {
39
+ target: name,
40
+ message: `"${name}" is listed ${count} times.`,
41
+ detail: 'The model cannot address a duplicated name; whichever the host registers last silently wins.',
42
+ spec: SPEC,
43
+ }));
44
+ }
45
+ }
46
+ if (out.length === 0 && ctx.tools.length > 0) {
47
+ out.push(result('schema.tool_name', 'Tool names are valid and unique', 'pass', 'error', {
48
+ message: `${ctx.tools.length} tool${ctx.tools.length === 1 ? '' : 's'} checked.`,
49
+ }));
50
+ }
51
+ return out;
52
+ },
53
+ };
54
+ export const toolDescriptionCheck = {
55
+ id: 'schema.tool_description',
56
+ title: 'Tools are described well enough for a model to choose between them',
57
+ severity: 'warn',
58
+ spec: SPEC,
59
+ run(ctx) {
60
+ const out = [];
61
+ for (const tool of ctx.tools) {
62
+ const name = tool?.name ?? '(unnamed)';
63
+ const desc = tool?.description;
64
+ if (!desc || !desc.trim()) {
65
+ out.push(result('schema.tool_description.missing', 'Tool has a description', 'fail', 'error', {
66
+ target: name,
67
+ message: 'No description.',
68
+ detail: 'The description is the only thing the model reads when deciding whether to call this tool. Without it the tool is effectively invisible.',
69
+ spec: SPEC,
70
+ }));
71
+ continue;
72
+ }
73
+ const trimmed = desc.trim();
74
+ if (PLACEHOLDER_DESCRIPTIONS.test(trimmed)) {
75
+ out.push(result('schema.tool_description.placeholder', 'Description is not a placeholder', 'fail', 'error', {
76
+ target: name,
77
+ message: `Description is a placeholder: "${trimmed}"`,
78
+ spec: SPEC,
79
+ }));
80
+ }
81
+ else if (trimmed.length < MIN_DESCRIPTION_CHARS) {
82
+ out.push(result('schema.tool_description.short', 'Description is substantive', 'warn', 'warn', {
83
+ target: name,
84
+ message: `Only ${trimmed.length} chars: "${trimmed}"`,
85
+ detail: 'State what it does, when to use it, and what it returns. Models pick the wrong tool when two terse descriptions overlap.',
86
+ spec: SPEC,
87
+ }));
88
+ }
89
+ }
90
+ if (out.length === 0 && ctx.tools.length > 0) {
91
+ out.push(result('schema.tool_description', 'Tools are described', 'pass', 'warn'));
92
+ }
93
+ return out;
94
+ },
95
+ };
96
+ function walkSchema(schema, path, visit, depth = 0) {
97
+ if (!isPlainObject(schema) || depth > 12)
98
+ return;
99
+ visit(schema, path);
100
+ if (isPlainObject(schema.properties)) {
101
+ for (const [key, child] of Object.entries(schema.properties)) {
102
+ walkSchema(child, `${path}.${key}`, visit, depth + 1);
103
+ }
104
+ }
105
+ if (schema.items && !Array.isArray(schema.items)) {
106
+ walkSchema(schema.items, `${path}[]`, visit, depth + 1);
107
+ }
108
+ }
109
+ export const inputSchemaCheck = {
110
+ id: 'schema.input_schema',
111
+ title: 'inputSchema is a usable JSON Schema',
112
+ severity: 'error',
113
+ spec: SPEC,
114
+ run(ctx) {
115
+ const out = [];
116
+ for (const tool of ctx.tools) {
117
+ const name = tool?.name ?? '(unnamed)';
118
+ const schema = tool?.inputSchema;
119
+ if (!schema) {
120
+ out.push(result('schema.input_schema.missing', 'Tool declares an inputSchema', 'fail', 'error', {
121
+ target: name,
122
+ message: 'No `inputSchema`.',
123
+ detail: 'Without it the model has to guess the argument shape, and most hosts refuse to register the tool at all.',
124
+ spec: SPEC,
125
+ }));
126
+ continue;
127
+ }
128
+ if (!isPlainObject(schema)) {
129
+ out.push(result('schema.input_schema.malformed', 'inputSchema is an object', 'fail', 'error', {
130
+ target: name,
131
+ message: `inputSchema is ${Array.isArray(schema) ? 'an array' : typeof schema}, not an object.`,
132
+ detail: preview(schema),
133
+ spec: SPEC,
134
+ }));
135
+ continue;
136
+ }
137
+ if (schema.type !== 'object') {
138
+ out.push(result('schema.input_schema.root_type', 'inputSchema root is type object', 'fail', 'error', {
139
+ target: name,
140
+ message: `Root \`type\` is ${schema.type === undefined ? 'absent' : `"${String(schema.type)}"`}, expected "object".`,
141
+ detail: 'Tool arguments are always a named-argument object. Hosts validate against this before dispatching.',
142
+ spec: SPEC,
143
+ }));
144
+ }
145
+ // required entries that do not exist are the most common schema bug:
146
+ // the model is told to send a field that the server never reads.
147
+ const props = isPlainObject(schema.properties) ? schema.properties : {};
148
+ if (Array.isArray(schema.required)) {
149
+ const orphans = schema.required.filter((r) => typeof r === 'string' && !(r in props));
150
+ if (orphans.length > 0) {
151
+ out.push(result('schema.input_schema.orphan_required', 'required fields exist in properties', 'fail', 'error', {
152
+ target: name,
153
+ message: `required lists ${orphans.map((o) => `"${o}"`).join(', ')}, which ${orphans.length === 1 ? 'is' : 'are'} not in properties.`,
154
+ detail: 'Strict validators reject every call to this tool, because the argument can never be supplied in a valid way.',
155
+ spec: SPEC,
156
+ }));
157
+ }
158
+ }
159
+ // Undescribed parameters force the model to infer meaning from the name.
160
+ const undescribed = [];
161
+ walkSchema(schema, name, (s, p) => {
162
+ if (p === name)
163
+ return;
164
+ if (!s.description && !s.enum && s.type !== 'object')
165
+ undescribed.push(p.slice(name.length + 1));
166
+ });
167
+ if (undescribed.length > 0) {
168
+ out.push(result('schema.input_schema.undescribed_params', 'Parameters carry descriptions', 'warn', 'warn', {
169
+ target: name,
170
+ message: `${undescribed.length} parameter${undescribed.length === 1 ? '' : 's'} with no description: ${undescribed.slice(0, 6).join(', ')}${undescribed.length > 6 ? ', ...' : ''}`,
171
+ detail: 'Parameter descriptions are how the model learns formats -- date layouts, id shapes, units, allowed ranges.',
172
+ spec: SPEC,
173
+ }));
174
+ }
175
+ if (Object.keys(props).length === 0 && schema.type === 'object' && schema.additionalProperties !== false) {
176
+ out.push(result('schema.input_schema.open_object', 'Schema constrains its arguments', 'warn', 'warn', {
177
+ target: name,
178
+ message: 'Schema is an object with no properties and additionalProperties is not false.',
179
+ detail: 'This accepts anything. If the tool truly takes no arguments, set `additionalProperties: false` to say so.',
180
+ spec: SPEC,
181
+ }));
182
+ }
183
+ }
184
+ if (out.length === 0 && ctx.tools.length > 0) {
185
+ out.push(result('schema.input_schema', 'inputSchema is usable', 'pass', 'error'));
186
+ }
187
+ return out;
188
+ },
189
+ };
190
+ /**
191
+ * Every tool definition is re-sent on every single request for the whole
192
+ * session. A bloated tool list is a permanent tax on the context window and
193
+ * shows up directly on the user's bill -- but nothing in the toolchain
194
+ * measures it, so it grows unnoticed.
195
+ */
196
+ export const contextWeightCheck = {
197
+ id: 'schema.context_weight',
198
+ title: 'Tool list does not dominate the context window',
199
+ severity: 'warn',
200
+ spec: SPEC,
201
+ run(ctx) {
202
+ if (ctx.tools.length === 0)
203
+ return [];
204
+ const perTool = ctx.tools
205
+ .map((tool) => ({ name: tool?.name ?? '(unnamed)', tokens: estimateTokens(JSON.stringify(tool ?? {})) }))
206
+ .sort((a, b) => b.tokens - a.tokens);
207
+ const total = perTool.reduce((sum, t) => sum + t.tokens, 0);
208
+ const heaviest = perTool
209
+ .slice(0, 5)
210
+ .map((t) => ` ${String(t.tokens).padStart(6)} tok ${t.name}`)
211
+ .join('\n');
212
+ const detail = `Estimated at ~4 chars/token from the serialised tool definitions.\n\nHeaviest tools:\n${heaviest}\n\nThis cost is paid on every request for the lifetime of the session, not once.`;
213
+ const status = total > 10_000 ? 'fail' : total > 4_000 ? 'warn' : 'pass';
214
+ const severity = total > 10_000 ? 'error' : 'warn';
215
+ return [
216
+ result('schema.context_weight', 'Tool list does not dominate the context window', status, severity, {
217
+ message: `~${total.toLocaleString('en-US')} tokens across ${ctx.tools.length} tool${ctx.tools.length === 1 ? '' : 's'}${status === 'pass' ? '' : ` (budget: 4,000 warn / 10,000 fail)`}`,
218
+ detail,
219
+ spec: SPEC,
220
+ }),
221
+ ];
222
+ },
223
+ };
224
+ export const schemaChecks = [toolNameCheck, toolDescriptionCheck, inputSchemaCheck, contextWeightCheck];
@@ -0,0 +1,7 @@
1
+ import type { CheckResult, Severity, Status } from '../types.js';
2
+ export declare function result(id: string, title: string, status: Status, severity: Severity, extra?: Partial<CheckResult>): CheckResult;
3
+ /** Trim a payload for display without hiding the part that matters. */
4
+ export declare function preview(value: unknown, max?: number): string;
5
+ export declare function isPlainObject(v: unknown): v is Record<string, unknown>;
6
+ /** Rough token estimate. Good enough to flag payloads that will blow context. */
7
+ export declare function estimateTokens(text: string): number;
@@ -0,0 +1,23 @@
1
+ export function result(id, title, status, severity, extra = {}) {
2
+ return { id, title, status, severity, ...extra };
3
+ }
4
+ /** Trim a payload for display without hiding the part that matters. */
5
+ export function preview(value, max = 300) {
6
+ let s;
7
+ try {
8
+ s = typeof value === 'string' ? value : JSON.stringify(value);
9
+ }
10
+ catch {
11
+ s = String(value);
12
+ }
13
+ if (s === undefined)
14
+ return 'undefined';
15
+ return s.length > max ? s.slice(0, max) + ` ... (+${s.length - max} chars)` : s;
16
+ }
17
+ export function isPlainObject(v) {
18
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
19
+ }
20
+ /** Rough token estimate. Good enough to flag payloads that will blow context. */
21
+ export function estimateTokens(text) {
22
+ return Math.ceil(text.length / 4);
23
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};