@tensorfeed/mcp-server 2.0.1 → 2.0.3

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
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to the [TensorFeed.ai MCP server](https://github.com/RipperMercs/tensorfeed-mcp). Free tools work without configuration; premium tools require a bearer token via the `TENSORFEED_TOKEN` env var. Buy credits at [tensorfeed.ai/developers/agent-payments](https://tensorfeed.ai/developers/agent-payments).
4
4
 
5
+ ## 2.0.3 - 2026-07-06
6
+
7
+ Error-path and input hardening, completing the 2.0.2 output work.
8
+
9
+ - Tool errors now pass through the same scrub as tool results. Previously a
10
+ thrown error's message (which can embed an upstream response body) went to
11
+ the host raw; every throw is now converted to a sanitized MCP tool error
12
+ with secrets redacted (the live bearer token, plus anything shaped like a
13
+ tf_live_ token), the standard control-char and role-token scrub applied,
14
+ and the text capped at 4k chars.
15
+ - Upstream error bodies embedded in error messages are capped at 1.5k chars
16
+ at the source.
17
+ - Every free-text tool parameter now carries a schema-level max length
18
+ (200 for identifiers, larger for comma lists, URLs, and search queries),
19
+ so oversized inputs are rejected by validation before reaching any code.
20
+
21
+ No tool signatures change and no behavior changes for valid inputs.
22
+
23
+ ## 2.0.2 - 2026-07-06
24
+
25
+ Output hardening. Caller-supplied identifiers that a tool echoes back in its
26
+ own message (a service, model, provider, benchmark, publisher domain, CVE id,
27
+ or search query that did not match) are now stripped of angle brackets and
28
+ length-capped before they are placed in the response text. This is a
29
+ defense-in-depth layer on top of the existing output sanitizer, so a value a
30
+ caller passes in cannot be reflected back as markup. No tool signatures,
31
+ endpoints, or behavior for valid inputs change.
32
+
5
33
  ## 2.0.1 - 2026-07-03
6
34
 
7
35
  Docs release, no code changes to the stdio server. The README now documents
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { z } from 'zod';
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { dirname, join } from 'node:path';
8
- import { sanitizeToolResponse, sanitizeForLLM } from './sanitize.js';
8
+ import { sanitizeToolResponse, sanitizeForLLM, sanitizeReflectedValue, sanitizeErrorText } from './sanitize.js';
9
9
  const API_BASE = 'https://tensorfeed.ai/api';
10
10
  // Per-request upstream timeout. fetchJSON is the single HTTP helper behind every
11
11
  // MCP tool and the latest_news resource, so a missing timeout would let one
@@ -67,16 +67,35 @@ async function fetchJSON(path, opts = {}) {
67
67
  catch {
68
68
  errPayload = await res.text().catch(() => '');
69
69
  }
70
+ // The upstream error body is untrusted input. Cap it here so a hostile
71
+ // or malfunctioning upstream (or a middlebox returning an HTML error
72
+ // page) cannot inflate the error message. The registerTool wrapper
73
+ // additionally runs the full error scrub before anything reaches the
74
+ // host LLM; this cap just bounds what gets embedded at the source.
75
+ const detail = errDetail(errPayload);
70
76
  if (res.status === 402) {
71
- throw new Error(`Payment required (402). Your token may be out of credits. Top up at https://tensorfeed.ai/developers/agent-payments. Detail: ${JSON.stringify(errPayload)}`);
77
+ throw new Error(`Payment required (402). Your token may be out of credits. Top up at https://tensorfeed.ai/developers/agent-payments. Detail: ${detail}`);
72
78
  }
73
79
  if (res.status === 401) {
74
- throw new Error(`Token rejected (401). Check that TENSORFEED_TOKEN is set to a valid tf_live_... token. Detail: ${JSON.stringify(errPayload)}`);
80
+ throw new Error(`Token rejected (401). Check that TENSORFEED_TOKEN is set to a valid tf_live_... token. Detail: ${detail}`);
75
81
  }
76
- throw new Error(`API error ${res.status}: ${JSON.stringify(errPayload)}`);
82
+ throw new Error(`API error ${res.status}: ${detail}`);
77
83
  }
78
84
  return res.json();
79
85
  }
86
+ // Stringify an upstream error payload for embedding in an Error message,
87
+ // bounded so an oversized body cannot balloon the error text.
88
+ const MAX_ERR_DETAIL_CHARS = 1500;
89
+ function errDetail(payload) {
90
+ let s;
91
+ try {
92
+ s = JSON.stringify(payload) ?? String(payload);
93
+ }
94
+ catch {
95
+ s = String(payload);
96
+ }
97
+ return s.length > MAX_ERR_DETAIL_CHARS ? s.slice(0, MAX_ERR_DETAIL_CHARS) + '...' : s;
98
+ }
80
99
  // ── Server setup ────────────────────────────────────────────────────
81
100
  const server = new McpServer({
82
101
  name: 'tensorfeed',
@@ -141,8 +160,25 @@ function registerTool(name, description, paramsSchema, cb, annotations) {
141
160
  // explicitly and bypass this inference.
142
161
  const finalAnnotations = annotations ?? (PAID_TOOL_RE.test(description) ? PREMIUM_READ_TOOL : READ_TOOL);
143
162
  const wrapped = (async (args, extra) => {
144
- const result = await cb(args, extra);
145
- return sanitizeToolResponse(result);
163
+ try {
164
+ const result = await cb(args, extra);
165
+ return sanitizeToolResponse(result);
166
+ }
167
+ catch (err) {
168
+ // The error path used to bypass the sanitizer entirely: a thrown
169
+ // Error's message (which can embed an upstream response body via
170
+ // fetchJSON) went to the SDK raw. Convert every throw into a
171
+ // sanitized MCP tool error instead: secrets redacted (the live
172
+ // bearer token, plus anything token-shaped), the standard
173
+ // control-char and role-token scrub applied, and the text capped,
174
+ // so the error channel offers no unsanitized path into the host
175
+ // LLM's context.
176
+ const raw = err instanceof Error ? err.message : String(err);
177
+ return {
178
+ content: [{ type: 'text', text: sanitizeErrorText(raw, [process.env.TENSORFEED_TOKEN]) }],
179
+ isError: true,
180
+ };
181
+ }
146
182
  });
147
183
  // Direct dispatch to the SDK's underlying registration. Do not call
148
184
  // registerTool() here or it will recurse forever.
@@ -188,7 +224,7 @@ server.registerResource('latest_news', 'tensorfeed://news/latest', {
188
224
  });
189
225
  // ── Tool: get_ai_news ───────────────────────────────────────────────
190
226
  registerTool('get_ai_news', 'Get the latest AI news from TensorFeed.ai as a ranked list with title, source, URL, snippet, and publish time, filterable by category (e.g. "anthropic", "openai", "research", "tools"). Aggregates 15+ sources (Anthropic, OpenAI, Google, TechCrunch, The Verge, arXiv, and more) into one normalized feed, so an agent reads one schema instead of polling each outlet. Free, no auth.', {
191
- category: z.string().optional().describe('Filter by category (e.g. "anthropic", "openai", "research", "tools")'),
227
+ category: z.string().max(100).optional().describe('Filter by category (e.g. "anthropic", "openai", "research", "tools")'),
192
228
  limit: z.number().min(1).max(50).optional().describe('Number of articles to return (default 10, max 50)'),
193
229
  digest: z.boolean().optional().describe('When true, return a headline-only digest (title, source, URL per story) instead of the full article set.'),
194
230
  }, async ({ category, limit, digest }) => {
@@ -219,7 +255,7 @@ registerTool('get_ai_news', 'Get the latest AI news from TensorFeed.ai as a rank
219
255
  });
220
256
  // ── Tool: find_tensorfeed_data (discovery, long-tail surface) ───────
221
257
  registerTool('find_tensorfeed_data', 'Discover which TensorFeed endpoint answers a data need. Describe what you want in plain language (e.g. "trending AI papers", "is OpenAI down", "model price history") and this returns the 2 to 3 best-matching TensorFeed endpoints, each with its HTTP path, what it returns, and whether it is free or paid. TensorFeed exposes 100+ AI-ecosystem data and signed-verdict endpoints; the core ones are also dedicated tools, but the full catalog is reachable here and callable over HTTP (paid ones via x402 or a credits token). Free, no auth. Use this first when no dedicated tool obviously fits.', {
222
- query: z.string().describe('Plain-language description of the data or decision you need.'),
258
+ query: z.string().max(500).describe('Plain-language description of the data or decision you need.'),
223
259
  limit: z.number().int().min(1).max(5).optional().describe('Max endpoints to return (default 3).'),
224
260
  }, async ({ query, limit }) => {
225
261
  const { flattenCatalog, scoreEndpoints } = await import('./discovery.js');
@@ -235,10 +271,10 @@ registerTool('find_tensorfeed_data', 'Discover which TensorFeed endpoint answers
235
271
  const rows = flattenCatalog(meta);
236
272
  const top = scoreEndpoints(rows, query, limit ?? 3);
237
273
  if (top.length === 0) {
238
- return { content: [{ type: 'text', text: `No TensorFeed endpoint matched "${query}". Browse the full catalog at https://tensorfeed.ai/api/meta or https://tensorfeed.ai/developers.` }] };
274
+ return { content: [{ type: 'text', text: `No TensorFeed endpoint matched "${sanitizeReflectedValue(query)}". Browse the full catalog at https://tensorfeed.ai/api/meta or https://tensorfeed.ai/developers.` }] };
239
275
  }
240
276
  const lines = top.map((r) => `- ${r.path}\n ${r.description}`).join('\n');
241
- return { content: [{ type: 'text', text: `Top TensorFeed endpoints for "${query}":\n${lines}\n\nCall free endpoints directly over HTTP. Paid endpoints (marked with a credit cost) accept x402 payment or a TENSORFEED_TOKEN bearer; see https://tensorfeed.ai/developers/agent-payments.` }] };
277
+ return { content: [{ type: 'text', text: `Top TensorFeed endpoints for "${sanitizeReflectedValue(query)}":\n${lines}\n\nCall free endpoints directly over HTTP. Paid endpoints (marked with a credit cost) accept x402 payment or a TENSORFEED_TOKEN bearer; see https://tensorfeed.ai/developers/agent-payments.` }] };
242
278
  });
243
279
  // ── Tool: get_ai_status ─────────────────────────────────────────────
244
280
  registerTool('get_ai_status', 'Get the real-time operational status of major AI services (Claude, OpenAI, Gemini, Mistral, Cohere, Replicate, Hugging Face), including per-component breakdowns and an operational/degraded/down rollup per provider. One cross-provider status call instead of checking each vendor\'s status page, useful before an agent routes a request to a model that may be impaired. Free, no auth.', {}, async () => {
@@ -255,13 +291,13 @@ registerTool('get_ai_status', 'Get the real-time operational status of major AI
255
291
  });
256
292
  // ── Tool: is_service_down ───────────────────────────────────────────
257
293
  registerTool('is_service_down', 'Check whether one named AI service (e.g. "claude", "openai", "gemini", "mistral", "cohere", "hugging face", "replicate") is currently operational, degraded, or down, with its component-level breakdown. Matches on service or provider name and lists available services if there is no match, so an agent can gate a call on live status before sending traffic. Free, no auth.', {
258
- service: z.string().describe('Service name to check (e.g. "claude", "openai", "gemini", "mistral", "cohere", "hugging face", "replicate")'),
294
+ service: z.string().max(200).describe('Service name to check (e.g. "claude", "openai", "gemini", "mistral", "cohere", "hugging face", "replicate")'),
259
295
  }, async ({ service }) => {
260
296
  const data = await fetchJSON('/status');
261
297
  const match = data.services.find(s => s.name.toLowerCase().includes(service.toLowerCase()) ||
262
298
  s.provider.toLowerCase().includes(service.toLowerCase()));
263
299
  if (!match) {
264
- return { content: [{ type: 'text', text: `Service "${service}" not found. Available services: ${data.services.map(s => s.name).join(', ')}` }] };
300
+ return { content: [{ type: 'text', text: `Service "${sanitizeReflectedValue(service)}" not found. Available services: ${data.services.map(s => s.name).join(', ')}` }] };
265
301
  }
266
302
  const statusEmoji = match.status === 'operational' ? 'OK' : match.status === 'degraded' ? 'DEGRADED' : 'DOWN';
267
303
  const components = match.components.length > 0
@@ -332,7 +368,7 @@ registerTool('account_status', 'Check the configured TensorFeed token: current c
332
368
  registerTool('route_verdict', "TensorFeed's signed model-routing decision: the single best model for a task or named model, fused from live pricing, contamination-discounted benchmarks, real production usage, measured p95 latency, incident state, and deprecation flags, with the reasoning. tier='preview' (default) is free (10 calls per day per IP), top verdict only. tier='full' costs 1 credit ($0.02), adds ranked runners-up, constraint filters, and an AFTA-signed receipt you can audit, and needs a TENSORFEED_TOKEN. Get credits at tensorfeed.ai/developers/agent-payments.", {
333
369
  tier: z.enum(['preview', 'full']).optional().describe("'preview' (default, free) or 'full' (1 credit; adds runners-up, filters, signed receipt)."),
334
370
  task: z.enum(['code', 'reasoning', 'creative', 'general']).optional().describe('Task type to route for (code, reasoning, creative, general). Provide task or model.'),
335
- model: z.string().optional().describe('Model id or display name to narrow the verdict to one model (e.g. "Claude Opus 4.7" or "claude-opus-4-7"). Provide task or model.'),
371
+ model: z.string().max(200).optional().describe('Model id or display name to narrow the verdict to one model (e.g. "Claude Opus 4.7" or "claude-opus-4-7"). Provide task or model.'),
336
372
  max_latency_p95_ms: z.number().optional().describe('Full tier only. Drop candidates whose measured p95 latency exceeds this value (ms).'),
337
373
  budget: z.number().optional().describe('Full tier only. Max blended USD per 1M tokens.'),
338
374
  min_quality: z.number().min(0).max(1).optional().describe('Full tier only. Minimum trust-discounted quality score in [0, 1].'),
@@ -549,7 +585,7 @@ registerTool('x402_settlement_verdict', "TensorFeed's signed ruling on the state
549
585
  // ── Tool: x402_publisher_verdict (preview free / full 1 credit) ──────
550
586
  registerTool('x402_publisher_verdict', "TensorFeed's signed trust verdict on one x402 publisher, over its OWN on-chain settlement index: whether a named publisher domain is actively settling, recently quiet, registered with no settlement, unreachable, missing a Base payTo, or not indexed. Requires a domain. tier='preview' (default) is free (10 calls per day per IP), the trust verdict only. tier='full' costs 1 credit ($0.02), adds the 30-day settlement momentum, the shared-wallet risk flag, the settlement evidence (volume, count, last settled), and an AFTA-signed receipt, and needs a TENSORFEED_TOKEN. Get credits at tensorfeed.ai/developers/agent-payments.", {
551
587
  tier: z.enum(['preview', 'full']).optional().describe("'preview' (default, free) or 'full' (1 credit; adds momentum, shared-wallet flag, evidence, signed receipt)."),
552
- domain: z.string().describe('Publisher domain to rule on, e.g. "x402.tavily.com". Required.'),
588
+ domain: z.string().max(253).describe('Publisher domain to rule on, e.g. "x402.tavily.com". Required.'),
553
589
  }, async ({ tier, domain }) => {
554
590
  if (!domain || !domain.trim()) {
555
591
  return { content: [{ type: 'text', text: 'Provide a publisher domain (e.g. domain "x402.tavily.com") to get a trust verdict.' }] };
@@ -563,7 +599,7 @@ registerTool('x402_publisher_verdict', "TensorFeed's signed trust verdict on one
563
599
  content: [
564
600
  {
565
601
  type: 'text',
566
- text: `x402 Publisher Verdict for ${data.domain}: ${data.verdict}\n` +
602
+ text: `x402 Publisher Verdict for ${sanitizeReflectedValue(data.domain)}: ${data.verdict}\n` +
567
603
  `Momentum: ${data.momentum}. Shared wallet: ${t.wallet_shared ? 'yes (risk flag)' : 'no'}` +
568
604
  (t.last_settled ? `. Last settled ${t.last_settled}` : '') +
569
605
  '\n' +
@@ -580,7 +616,7 @@ registerTool('x402_publisher_verdict', "TensorFeed's signed trust verdict on one
580
616
  content: [
581
617
  {
582
618
  type: 'text',
583
- text: `x402 Publisher Verdict for ${data.domain}: ${data.verdict}\n` +
619
+ text: `x402 Publisher Verdict for ${sanitizeReflectedValue(data.domain)}: ${data.verdict}\n` +
584
620
  `Claim: ${data.claim}` +
585
621
  (data.captured_at ? `\nIndex captured ${data.captured_at}` : '') +
586
622
  '\n' +
@@ -591,7 +627,7 @@ registerTool('x402_publisher_verdict', "TensorFeed's signed trust verdict on one
591
627
  });
592
628
  registerTool('stack_safety_verdict', "TensorFeed's deploy gate for an AI software stack: pass each package as comma-separated name@version and get the overall BLOCK / HOLD / PASS / UNKNOWN gate plus a per-package verdict, fusing the ingested AI-stack CVE batch with the CISA KEV catalog. Conservative by design: BLOCK only on an exploited CVE with no fix, HOLD when a known CVE applies and you must verify your version, PASS on no match, UNKNOWN outside the curated AI-stack cohort. tier='preview' (default) is free (10 calls per day per IP), caps at 3 packages, gate plus worst offender. tier='full' costs 1 credit ($0.02), raises the cap to 10 packages, adds the matched-CVE evidence (ids, affected ranges, fixed versions, KEV status) and an AFTA-signed receipt, and needs a TENSORFEED_TOKEN. Get credits at tensorfeed.ai/developers/agent-payments.", {
593
629
  tier: z.enum(['preview', 'full']).optional().describe("'preview' (default, free, caps at 3 packages) or 'full' (1 credit; up to 10 packages, matched-CVE evidence, signed receipt)."),
594
- packages: z.string().describe('Comma-separated AI-stack packages as name@version (e.g. "vllm@0.5.0,transformers@4.40.0"). Required. Preview caps at 3, full up to 10.'),
630
+ packages: z.string().max(1000).describe('Comma-separated AI-stack packages as name@version (e.g. "vllm@0.5.0,transformers@4.40.0"). Required. Preview caps at 3, full up to 10.'),
595
631
  }, async ({ tier, packages }) => {
596
632
  if (!packages || !packages.trim()) {
597
633
  return { content: [{ type: 'text', text: 'Provide packages as a comma-separated name@version list (e.g. "vllm@0.5.0,transformers@4.40.0") to get a deploy gate.' }] };
@@ -643,8 +679,8 @@ registerTool('stack_safety_verdict', "TensorFeed's deploy gate for an AI softwar
643
679
  });
644
680
  registerTool('benchmark_trust_verdict', "TensorFeed's signed ruling on whether an AI benchmark is still a trustworthy capability signal or saturated, contaminated, or near ceiling so a high score should be down-weighted: a trust band (reliable, use_with_caution, saturated, contaminated, deprecated) and a 0-100 trust score per benchmark. Pass benchmark to narrow to one, or category to filter, or neither for the registry. tier='preview' (default) is free (10 calls per day per IP), top verdict and bands only. tier='full' costs 1 credit ($0.02), adds the per-signal detail (ceiling proximity, frontier compression, contamination), a down-weight recommendation with an alternative benchmark, and an AFTA-signed receipt, and needs a TENSORFEED_TOKEN. Get credits at tensorfeed.ai/developers/agent-payments.", {
645
681
  tier: z.enum(['preview', 'full']).optional().describe("'preview' (default, free) or 'full' (1 credit; adds per-signal detail, recommendation, signed receipt)."),
646
- benchmark: z.string().optional().describe('Benchmark registry id or name to narrow to one (e.g. "mmlu", "swe-bench"). Optional.'),
647
- category: z.string().optional().describe('Category to filter the benchmarks (e.g. "coding", "reasoning"). Optional.'),
682
+ benchmark: z.string().max(200).optional().describe('Benchmark registry id or name to narrow to one (e.g. "mmlu", "swe-bench"). Optional.'),
683
+ category: z.string().max(100).optional().describe('Category to filter the benchmarks (e.g. "coding", "reasoning"). Optional.'),
648
684
  }, async ({ tier, benchmark, category }) => {
649
685
  const params = new URLSearchParams();
650
686
  if (benchmark)
@@ -699,7 +735,7 @@ registerTool('benchmark_trust_verdict', "TensorFeed's signed ruling on whether a
699
735
  });
700
736
  registerTool('failover_verdict', "Provider A is degraded; TensorFeed's signed ruling on the single best operational provider to fail over to for a task right now. Confirms A against the live incident-triage feed, then runs the route-verdict fusion with A (and any provider already in failover) excluded. Requires from. tier='preview' (default) is free (10 calls per day per IP), the failover target only. tier='full' costs 1 credit ($0.02), adds the full candidate (pricing, measured p95 latency, quality), the ranked alternatives, the confirmed incident on A, and an AFTA-signed receipt, and needs a TENSORFEED_TOKEN. Get credits at tensorfeed.ai/developers/agent-payments.", {
701
737
  tier: z.enum(['preview', 'full']).optional().describe("'preview' (default, free) or 'full' (1 credit; adds candidate detail, ranked alternatives, incident, signed receipt)."),
702
- from: z.string().describe('The degraded provider to fail over FROM (e.g. "anthropic", "openai"). Required.'),
738
+ from: z.string().max(200).describe('The degraded provider to fail over FROM (e.g. "anthropic", "openai"). Required.'),
703
739
  task: z.enum(['code', 'reasoning', 'creative', 'general']).optional().describe('Task type to optimize the failover target for. Optional.'),
704
740
  }, async ({ tier, from, task }) => {
705
741
  if (!from || !from.trim()) {
@@ -711,15 +747,16 @@ registerTool('failover_verdict', "Provider A is degraded; TensorFeed's signed ru
711
747
  if ((tier ?? 'preview') === 'full') {
712
748
  const data = (await fetchJSON(`/premium/failover-verdict?${params}`, { auth: true }));
713
749
  const dest = data.failover_to;
750
+ const fromProvider = sanitizeReflectedValue(data.from.provider);
714
751
  const incidentLine = data.from.incident
715
- ? `Incident on ${data.from.provider}: ${data.from.incident.title} (action ${data.from.incident.recommended_action})\n`
752
+ ? `Incident on ${fromProvider}: ${data.from.incident.title} (action ${data.from.incident.recommended_action})\n`
716
753
  : '';
717
754
  if (!dest) {
718
755
  return {
719
756
  content: [
720
757
  {
721
758
  type: 'text',
722
- text: `Failover Verdict from ${data.from.provider}: no operational failover target found.\n` +
759
+ text: `Failover Verdict from ${fromProvider}: no operational failover target found.\n` +
723
760
  incidentLine +
724
761
  `Why: ${data.why}\nClaim: ${data.claim}` +
725
762
  verdictBilling(data.billing),
@@ -734,7 +771,7 @@ registerTool('failover_verdict', "Provider A is degraded; TensorFeed's signed ru
734
771
  content: [
735
772
  {
736
773
  type: 'text',
737
- text: `Failover Verdict from ${data.from.provider}: fail over to ${dest.model.name} (${dest.model.provider})\n` +
774
+ text: `Failover Verdict from ${fromProvider}: fail over to ${dest.model.name} (${dest.model.provider})\n` +
738
775
  `Why: ${dest.why}\n` +
739
776
  `Blended $${dest.pricing.blended}/1M, p95 ${dest.latency.measured_p95_ms ?? 'n/a'}ms, status ${dest.operational.status}\n` +
740
777
  incidentLine +
@@ -748,13 +785,14 @@ registerTool('failover_verdict', "Provider A is degraded; TensorFeed's signed ru
748
785
  }
749
786
  const data = (await fetchJSON(`/preview/failover-verdict?${params}`));
750
787
  const dest = data.failover_to;
788
+ const fromProvider = sanitizeReflectedValue(data.from.provider);
751
789
  const rl = previewRateLine(data.rate_limit);
752
790
  if (!dest) {
753
791
  return {
754
792
  content: [
755
793
  {
756
794
  type: 'text',
757
- text: `Failover Verdict from ${data.from.provider}: no operational failover target found.\n` +
795
+ text: `Failover Verdict from ${fromProvider}: no operational failover target found.\n` +
758
796
  `Why: ${data.why}\n${data.claim}\n` +
759
797
  verdictUpsell('failover_verdict', 'the full failover candidate (pricing, measured latency, quality) and the ranked alternatives', rl),
760
798
  },
@@ -765,7 +803,7 @@ registerTool('failover_verdict', "Provider A is degraded; TensorFeed's signed ru
765
803
  content: [
766
804
  {
767
805
  type: 'text',
768
- text: `Failover Verdict from ${data.from.provider}` +
806
+ text: `Failover Verdict from ${fromProvider}` +
769
807
  (data.from.in_incident ? ' (confirmed in incident)' : '') +
770
808
  `: fail over to ${dest.model.name} (${dest.model.provider})\n` +
771
809
  `Why: ${data.why}\n` +
@@ -779,7 +817,7 @@ registerTool('failover_verdict', "Provider A is degraded; TensorFeed's signed ru
779
817
  // ── Tool: ssvc_verdict (preview free / full 1 credit) ────────────────
780
818
  registerTool('ssvc_verdict', "TensorFeed's signed SSVC patch-urgency decision for one CVE, applying the CISA SSVC Coordinator decision tree to the recorded Vulnrichment decision points (exploitation, automatable, technical impact). Requires a CVE id. tier='preview' (default) is free (10 calls per day per IP): the three decision points and the decision-tree provenance, WITHOUT the computed Act / Attend / Track / Track* decision. tier='full' costs 1 credit ($0.02), adds the computed decision across the full low/medium/high Mission and Well-being envelope, the per-level reasoning, a live CISA KEV cross-check, and an AFTA-signed receipt, and needs a TENSORFEED_TOKEN. Get credits at tensorfeed.ai/developers/agent-payments.", {
781
819
  tier: z.enum(['preview', 'full']).optional().describe("'preview' (default, free) or 'full' (1 credit; adds the computed decision, envelope, reasoning, KEV cross-check, signed receipt)."),
782
- cve: z.string().describe('CVE id to rule on, e.g. "CVE-2024-3094". Required.'),
820
+ cve: z.string().max(64).describe('CVE id to rule on, e.g. "CVE-2024-3094". Required.'),
783
821
  }, async ({ tier, cve }) => {
784
822
  if (!cve || !cve.trim()) {
785
823
  return { content: [{ type: 'text', text: 'Provide a CVE id (e.g. cve "CVE-2024-3094") to get the SSVC decision points.' }] };
@@ -797,7 +835,7 @@ registerTool('ssvc_verdict', "TensorFeed's signed SSVC patch-urgency decision fo
797
835
  content: [
798
836
  {
799
837
  type: 'text',
800
- text: `SSVC Verdict for ${data.cve}: ${data.decision_primary} (Mission and Well-being assumed medium)\n` +
838
+ text: `SSVC Verdict for ${sanitizeReflectedValue(data.cve)}: ${data.decision_primary} (Mission and Well-being assumed medium)\n` +
801
839
  `Envelope: low ${env.low}, medium ${env.medium}, high ${env.high}\n` +
802
840
  `Decision points: exploitation ${dp.exploitation}, automatable ${dp.automatable}, technical impact ${dp.technical_impact}\n` +
803
841
  `${kevLine}\n` +
@@ -815,7 +853,7 @@ registerTool('ssvc_verdict', "TensorFeed's signed SSVC patch-urgency decision fo
815
853
  content: [
816
854
  {
817
855
  type: 'text',
818
- text: `SSVC decision points for ${data.cve}:\n` +
856
+ text: `SSVC decision points for ${sanitizeReflectedValue(data.cve)}:\n` +
819
857
  ` exploitation: ${dp.exploitation}\n` +
820
858
  ` automatable: ${dp.automatable}\n` +
821
859
  ` technical impact: ${dp.technical_impact}\n` +
@@ -837,7 +875,7 @@ function seriesFromDate(days) {
837
875
  }
838
876
  // ── Tool: pricing_series (free 1 to 7 days, paid 8 to 90 days) ──────
839
877
  registerTool('pricing_series', 'Daily price points for one AI model over a window. days 1 to 7 is free; days 8 to 90 costs 1 credit ($0.02) and needs a TENSORFEED_TOKEN, adding the min/max/delta summary over the longer window. Get credits at tensorfeed.ai/developers/agent-payments.', {
840
- model: z.string().describe('Model id or display name (e.g. "Claude Opus 4.7" or "claude-opus-4-7").'),
878
+ model: z.string().max(200).describe('Model id or display name (e.g. "Claude Opus 4.7" or "claude-opus-4-7").'),
841
879
  days: z.number().int().min(1).max(90).optional().describe('Window length (default 7). 1 to 7 free; 8 to 90 costs 1 credit.'),
842
880
  }, async ({ model, days }) => {
843
881
  const d = days ?? 7;
@@ -852,7 +890,7 @@ registerTool('pricing_series', 'Daily price points for one AI model over a windo
852
890
  content: [
853
891
  {
854
892
  type: 'text',
855
- text: `${data.model} (${data.provider ?? 'unknown'}) ${data.points.length} points\n${summary}\nCredits remaining: ${data.billing?.credits_remaining ?? '?'}`,
893
+ text: `${sanitizeReflectedValue(data.model)} (${sanitizeReflectedValue(data.provider ?? 'unknown')}) ${data.points.length} points\n${summary}\nCredits remaining: ${data.billing?.credits_remaining ?? '?'}`,
856
894
  },
857
895
  ],
858
896
  };
@@ -867,15 +905,15 @@ registerTool('pricing_series', 'Daily price points for one AI model over a windo
867
905
  content: [
868
906
  {
869
907
  type: 'text',
870
- text: `${data.model} (${data.provider ?? 'unknown'}) ${data.points.length} points over ${data.points.length} days\n${summary}\nFor up to 90 days, pass days 8 to 90 (1 credit).`,
908
+ text: `${sanitizeReflectedValue(data.model)} (${sanitizeReflectedValue(data.provider ?? 'unknown')}) ${data.points.length} points over ${data.points.length} days\n${summary}\nFor up to 90 days, pass days 8 to 90 (1 credit).`,
871
909
  },
872
910
  ],
873
911
  };
874
912
  });
875
913
  // ── Tool: benchmark_series (free 1 to 7 days, paid 8 to 90 days) ────
876
914
  registerTool('benchmark_series', 'Daily benchmark scores for one model+benchmark over a window. Benchmark keys: swe_bench, mmlu_pro, gpqa_diamond, math, human_eval. days 1 to 7 is free; days 8 to 90 costs 1 credit ($0.02) and needs a TENSORFEED_TOKEN, tracking score evolution over the longer window. Get credits at tensorfeed.ai/developers/agent-payments.', {
877
- model: z.string().describe('Model id or display name.'),
878
- benchmark: z.string().describe('Benchmark key (e.g. swe_bench, mmlu_pro, gpqa_diamond, math, human_eval).'),
915
+ model: z.string().max(200).describe('Model id or display name.'),
916
+ benchmark: z.string().max(200).describe('Benchmark key (e.g. swe_bench, mmlu_pro, gpqa_diamond, math, human_eval).'),
879
917
  days: z.number().int().min(1).max(90).optional().describe('Window length (default 7). 1 to 7 free; 8 to 90 costs 1 credit.'),
880
918
  }, async ({ model, benchmark, days }) => {
881
919
  const d = days ?? 7;
@@ -890,7 +928,7 @@ registerTool('benchmark_series', 'Daily benchmark scores for one model+benchmark
890
928
  content: [
891
929
  {
892
930
  type: 'text',
893
- text: `${data.model} on ${data.benchmark}: ${data.points.length} points\n${summary}\nCredits remaining: ${data.billing?.credits_remaining ?? '?'}`,
931
+ text: `${sanitizeReflectedValue(data.model)} on ${sanitizeReflectedValue(data.benchmark)}: ${data.points.length} points\n${summary}\nCredits remaining: ${data.billing?.credits_remaining ?? '?'}`,
894
932
  },
895
933
  ],
896
934
  };
@@ -905,14 +943,14 @@ registerTool('benchmark_series', 'Daily benchmark scores for one model+benchmark
905
943
  content: [
906
944
  {
907
945
  type: 'text',
908
- text: `${data.model} on ${data.benchmark}: ${data.points.length} points\n${summary}`,
946
+ text: `${sanitizeReflectedValue(data.model)} on ${sanitizeReflectedValue(data.benchmark)}: ${data.points.length} points\n${summary}`,
909
947
  },
910
948
  ],
911
949
  };
912
950
  });
913
951
  // ── Tool: status_uptime (free 1 to 7 days, paid 8 to 90 days) ───────
914
952
  registerTool('status_uptime', 'Daily uptime rollup for one provider over a window with operational/degraded/down day counts and uptime % (degraded counts as half-credit). days 1 to 7 is free; days 8 to 90 costs 1 credit ($0.02) and needs a TENSORFEED_TOKEN, adding per-incident-day detail over the longer window. Get credits at tensorfeed.ai/developers/agent-payments.', {
915
- provider: z.string().describe('Provider name (e.g. anthropic, openai, google).'),
953
+ provider: z.string().max(200).describe('Provider name (e.g. anthropic, openai, google).'),
916
954
  days: z.number().int().min(1).max(90).optional().describe('Window length (default 7). 1 to 7 free; 8 to 90 costs 1 credit.'),
917
955
  }, async ({ provider, days }) => {
918
956
  const d = days ?? 7;
@@ -926,7 +964,7 @@ registerTool('status_uptime', 'Daily uptime rollup for one provider over a windo
926
964
  content: [
927
965
  {
928
966
  type: 'text',
929
- text: `${data.provider} uptime: ${data.uptime_pct ?? 'n/a'}% over ${data.days_with_data} measured days (of ${data.days_total} in range)\n` +
967
+ text: `${sanitizeReflectedValue(data.provider)} uptime: ${data.uptime_pct ?? 'n/a'}% over ${data.days_with_data} measured days (of ${data.days_total} in range)\n` +
930
968
  ` operational: ${data.days_operational}, degraded: ${data.days_degraded}, down: ${data.days_down}` +
931
969
  incidents +
932
970
  `\nCredits remaining: ${data.billing?.credits_remaining ?? '?'}`,
@@ -940,7 +978,7 @@ registerTool('status_uptime', 'Daily uptime rollup for one provider over a windo
940
978
  content: [
941
979
  {
942
980
  type: 'text',
943
- text: `${data.provider} over ${data.days_total} days: ${data.uptime_pct ?? '?'}% uptime (${data.days_operational} operational, ${data.days_degraded} degraded, ${data.days_down} down)`,
981
+ text: `${sanitizeReflectedValue(data.provider)} over ${data.days_total} days: ${data.uptime_pct ?? '?'}% uptime (${data.days_operational} operational, ${data.days_degraded} degraded, ${data.days_down} down)`,
944
982
  },
945
983
  ],
946
984
  };
@@ -1025,12 +1063,12 @@ registerTool('whats_new', 'Catch up on everything that changed in AI in one call
1025
1063
  });
1026
1064
  // ── Tool: compare_models (1 credit) ─────────────────────────────────
1027
1065
  registerTool('compare_models', 'Pick between models in one call: pricing, benchmarks, status, and recent news for 2 to 5 models side by side, with cheapest-blended and per-benchmark rankings, so you choose without scraping each provider. Costs 1 credit ($0.02).', {
1028
- ids: z.string().describe('Comma-separated list of 2-5 model ids or display names. Examples: "Claude Opus 4.8,GPT-5.5,Gemini 3.5 Flash" or "opus-4-8,gpt-5-5"'),
1066
+ ids: z.string().max(500).describe('Comma-separated list of 2-5 model ids or display names. Examples: "Claude Opus 4.8,GPT-5.5,Gemini 3.5 Flash" or "opus-4-8,gpt-5-5"'),
1029
1067
  }, async ({ ids }) => {
1030
1068
  const data = (await fetchJSON(`/premium/compare/models?ids=${encodeURIComponent(ids)}`, { auth: true }));
1031
1069
  const lines = data.models.map(m => {
1032
1070
  if (!m.matched)
1033
- return ` ${m.query}: not found`;
1071
+ return ` ${sanitizeReflectedValue(m.query)}: not found`;
1034
1072
  const benches = data.benchmark_keys
1035
1073
  .map(k => `${k}=${m.benchmarks[k] ?? '-'}`)
1036
1074
  .join(', ');
@@ -1052,7 +1090,7 @@ registerTool('compare_models', 'Pick between models in one call: pricing, benchm
1052
1090
  });
1053
1091
  // ── Tool: provider_deepdive (1 credit) ──────────────────────────────
1054
1092
  registerTool('provider_deepdive', 'Everything about one AI provider in a single call: live status, every model with pricing, tier, and benchmarks joined in, recent news, and agent traffic, replacing about four separate lookups. Costs 3 credits ($0.06). Strict premium, no free trial.', {
1055
- provider: z.string().describe('Provider id or display name (case-insensitive). Examples: anthropic, openai, google, mistral, cohere'),
1093
+ provider: z.string().max(200).describe('Provider id or display name (case-insensitive). Examples: anthropic, openai, google, mistral, cohere'),
1056
1094
  }, async ({ provider }) => {
1057
1095
  const data = (await fetchJSON(`/premium/providers/${encodeURIComponent(provider)}`, { auth: true }));
1058
1096
  const modelLines = data.models
@@ -1103,12 +1141,12 @@ registerTool('list_watches', 'List the active webhook watches owned by the confi
1103
1141
  // create_*_watch tool used, against the same /premium/watches endpoint.
1104
1142
  registerTool('create_watch', 'Register a webhook watch. type selects what to watch: "price" (a model price change), "status" (a service status transition), "digest" (a scheduled daily or weekly pricing summary), or "leaderboard_rank" (a provider crossing an uptime-rank threshold). Costs 1 credit ($0.02) at registration; the watch lives 90 days and each fire is an HMAC-signed POST to callback_url. Needs a TENSORFEED_TOKEN.', {
1105
1143
  type: z.enum(['price', 'status', 'digest', 'leaderboard_rank']).describe('What to watch.'),
1106
- callback_url: z.string().describe('HTTPS URL that receives the HMAC-signed POST when the watch fires.'),
1107
- secret: z.string().optional().describe('Optional shared secret used to HMAC-sign delivery bodies.'),
1108
- model: z.string().optional().describe('type=price only. Model name (e.g. "Claude Opus 4.7").'),
1144
+ callback_url: z.string().max(2048).describe('HTTPS URL that receives the HMAC-signed POST when the watch fires.'),
1145
+ secret: z.string().max(256).optional().describe('Optional shared secret used to HMAC-sign delivery bodies.'),
1146
+ model: z.string().max(200).optional().describe('type=price only. Model name (e.g. "Claude Opus 4.7").'),
1109
1147
  field: z.enum(['inputPrice', 'outputPrice', 'blended']).optional().describe('type=price only. Which price field to watch.'),
1110
1148
  cadence: z.enum(['daily', 'weekly']).optional().describe('type=digest only. How often the digest fires.'),
1111
- provider: z.string().optional().describe('type=status or type=leaderboard_rank only. Provider name or slug (case-insensitive). e.g. anthropic, openai, gemini, bedrock, azure.'),
1149
+ provider: z.string().max(200).optional().describe('type=status or type=leaderboard_rank only. Provider name or slug (case-insensitive). e.g. anthropic, openai, gemini, bedrock, azure.'),
1112
1150
  op: z
1113
1151
  .enum(['lt', 'gt', 'changes', 'becomes', 'drops_below', 'rises_above'])
1114
1152
  .optional()
@@ -1209,9 +1247,10 @@ registerTool('create_watch', 'Register a webhook watch. type selects what to wat
1209
1247
  if (secret !== undefined)
1210
1248
  body.secret = secret;
1211
1249
  const data = (await fetchJSON('/premium/watches', { method: 'POST', body, auth: true }));
1250
+ const safeProvider = sanitizeReflectedValue(provider);
1212
1251
  const desc = op === 'changes'
1213
- ? `${provider} rank changes`
1214
- : `${provider} ${(op ?? '').replace('_', ' ')} #${threshold}`;
1252
+ ? `${safeProvider} rank changes`
1253
+ : `${safeProvider} ${(op ?? '').replace('_', ' ')} #${threshold}`;
1215
1254
  return {
1216
1255
  content: [
1217
1256
  {
@@ -28,6 +28,40 @@
28
28
  * Scrub a single string for return to a host LLM. Idempotent and pure.
29
29
  */
30
30
  export declare function sanitizeForLLM(input: string): string;
31
+ /**
32
+ * Scrub an error message before it is returned to the host LLM as a tool
33
+ * error. Errors are the one path that used to bypass sanitizeToolResponse:
34
+ * a thrown Error's message (which may embed an upstream response body) went
35
+ * to the SDK raw. This applies, in order:
36
+ *
37
+ * 1. Redact any caller-provided secret values (e.g. the live
38
+ * TENSORFEED_TOKEN) wherever they appear in the text.
39
+ * 2. Redact anything shaped like a TensorFeed bearer token
40
+ * (tf_live_...), even if the exact env value was not passed, in case
41
+ * an upstream or proxy ever echoes an Authorization header back.
42
+ * 3. Run the standard sanitizeForLLM scrub (control chars, bidi and
43
+ * zero-width chars, role-confusion tokens).
44
+ * 4. Cap the result at MAX_ERROR_CHARS.
45
+ *
46
+ * Secrets shorter than 8 chars are ignored to avoid redacting common
47
+ * substrings if a trivial value is ever passed by mistake.
48
+ */
49
+ export declare function sanitizeErrorText(input: string, secrets?: (string | undefined)[]): string;
50
+ /**
51
+ * Neutralize a caller-supplied identifier before echoing it back inside a
52
+ * human-readable message (for example `Service "X" not found`). Model,
53
+ * provider, service and benchmark names are the only caller text that lands
54
+ * in our response strings, and a well-formed one never contains angle
55
+ * brackets. Stripping `<` and `>` means a value like
56
+ * `<script>alert(1)</script>` cannot be reflected as live markup into a
57
+ * downstream web renderer, nor masquerade as a tag in an LLM's context. The
58
+ * length cap stops a caller padding the message. This runs on echoed caller
59
+ * input only, never on the structured data we return, so arrow notation
60
+ * (`->`) and other formatted output elsewhere is untouched. The full
61
+ * response still passes through sanitizeForLLM afterward for control-char
62
+ * and role-token scrubbing.
63
+ */
64
+ export declare function sanitizeReflectedValue(input: unknown): string;
31
65
  /**
32
66
  * Recursively scrub the `text` field of every text-content item in an
33
67
  * MCP tool response. Non-text items (resources, images) pass through
package/dist/sanitize.js CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tensorfeed/mcp-server",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "mcpName": "ai.tensorfeed/mcp-server",
5
5
  "description": "MCP server for TensorFeed.ai - AI news, service status, model pricing, signed decision verdicts, time series, model comparison, webhook watches, and a discovery tool for the full TensorFeed data catalog, for AI agents",
6
6
  "main": "dist/index.js",
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "ai.tensorfeed/mcp-server",
4
4
  "title": "TensorFeed",
5
5
  "description": "AI news, model pricing, service status, and machine-payable premium tools. AFTA-certified.",
6
- "version": "2.0.1",
6
+ "version": "2.0.3",
7
7
  "websiteUrl": "https://tensorfeed.ai/developers/agent-payments",
8
8
  "repository": {
9
9
  "url": "https://github.com/RipperMercs/tensorfeed-mcp",
@@ -20,7 +20,7 @@
20
20
  "registryType": "npm",
21
21
  "registryBaseUrl": "https://registry.npmjs.org",
22
22
  "identifier": "@tensorfeed/mcp-server",
23
- "version": "2.0.1",
23
+ "version": "2.0.3",
24
24
  "runtimeHint": "npx",
25
25
  "transport": {
26
26
  "type": "stdio"