@corva/ui 3.78.0-15 → 3.78.0-17

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/MCP_README.md CHANGED
@@ -711,9 +711,17 @@ To check whether telemetry is currently active for your install, run `/mcp__corv
711
711
 
712
712
  To opt out entirely, set `CORVA_UI_MCP_TELEMETRY_DISABLED=1` in the server's environment (e.g. in the `env` block of
713
713
  your MCP config file) — any non-empty value other than `0` or `false` works. The switch is honored at runtime before
714
- any telemetry configuration is read. Feedback travels over the same channel and is subject to the same sampling rate:
715
- `submit_feedback` and the `feedback` prompt always confirm the message, but with telemetry off nothing reaches the
716
- maintainers, and with a sampling rate below 1 some messages are dropped.
714
+ any telemetry configuration is read.
715
+
716
+ Feedback travels over the same channel, so `submit_feedback` reports one of 2 delivery states:
717
+
718
+ - **queued** — telemetry is enabled and the message was handed to it for export. Delivery is not confirmed. Feedback is
719
+ exempt from the sampling rate, so it is never sampled out.
720
+ - **not sent** — nothing reaches the maintainers, and the response names the cause: the opt-out is set
721
+ (`CORVA_UI_MCP_TELEMETRY_DISABLED`, or `"enabled": false` in the local config), or telemetry has no valid
722
+ configuration in this session (or failed to start).
723
+
724
+ The `feedback` prompt relays that state to you, and `get_diagnostics` shows it as the feedback channel.
717
725
 
718
726
  ## FAQ
719
727
 
@@ -5,7 +5,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
5
5
  import { OTLPMetricExporter, AggregationTemporalityPreference } from '@opentelemetry/exporter-metrics-otlp-http';
6
6
  import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
7
7
  import { SpanKind, SpanStatusCode, propagation, context } from '@opentelemetry/api';
8
- import { NodeTracerProvider, TraceIdRatioBasedSampler, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node';
8
+ import { TraceIdRatioBasedSampler, SamplingDecision, NodeTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node';
9
9
  import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
10
10
  import { randomUUID } from 'crypto';
11
11
  import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources';
@@ -246,9 +246,9 @@ const validateDsn = dsn => {
246
246
  return url.href;
247
247
  };
248
248
 
249
- const MCP_SERVER_VERSION = '1.7.0';
249
+ const MCP_SERVER_VERSION = '1.9.0';
250
250
 
251
- var version = "3.78.0-15";
251
+ var version = "3.78.0-17";
252
252
 
253
253
  const CORVA_UI_VERSION = version;
254
254
 
@@ -535,11 +535,24 @@ const recordInitSpan = (tracer, attrs) => {
535
535
  }
536
536
  };
537
537
 
538
+ // Feedback leaves the process only as this span, so a ratio below 1 would silently lose messages.
539
+ // Matched by name: recordToolSpan sets gen_ai.tool.name after startSpan, too late for a sampler.
540
+ const FEEDBACK_SPAN_NAME = 'tools/call submit_feedback';
541
+ const createSampler = samplingRate => {
542
+ const ratioSampler = new TraceIdRatioBasedSampler(samplingRate);
543
+ return {
544
+ shouldSample: (context, traceId, spanName) => spanName === FEEDBACK_SPAN_NAME ? {
545
+ decision: SamplingDecision.RECORD_AND_SAMPLED
546
+ } : ratioSampler.shouldSample(context, traceId),
547
+ toString: () => `FeedbackAlwaysOn{${ratioSampler.toString()}}`
548
+ };
549
+ };
550
+
538
551
  const createTracerProvider = config => {
539
552
  const dsn = validateDsn(config.dsn);
540
553
  return new NodeTracerProvider({
541
554
  resource: config.resource,
542
- sampler: new TraceIdRatioBasedSampler(config.samplingRate),
555
+ sampler: createSampler(config.samplingRate),
543
556
  spanProcessors: [
544
557
  // Export spans immediately at end for short-lived local MCP sessions.
545
558
  new SimpleSpanProcessor(new OTLPTraceExporter({
@@ -659,7 +672,7 @@ const readLocalConfig = async logger => {
659
672
  // Fail closed: the file's presence signals the user wants local control over telemetry,
660
673
  // so a broken file must never silently fall back to the remote config that re-enables it.
661
674
  logger.warn(`Local telemetry config file exists but is invalid: ${configPath}. Telemetry disabled (fix or delete the file to re-enable).`);
662
- return 'disabled';
675
+ return 'invalid';
663
676
  } catch (error) {
664
677
  if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
665
678
  // File doesn't exist, which is normal. Silently skip.
@@ -668,10 +681,13 @@ const readLocalConfig = async logger => {
668
681
 
669
682
  // Other errors (JSON parse errors, permissions, etc.) — same fail-closed rule as above.
670
683
  logger.warn(`Failed to read local telemetry config from ${configPath}: ${error instanceof Error ? error.message : String(error)}. Telemetry disabled (fix or delete the file to re-enable).`);
671
- return 'disabled';
684
+ return 'invalid';
672
685
  }
673
686
  };
674
687
 
688
+ /** The user turned telemetry off, as opposed to it being unavailable. */
689
+ const TELEMETRY_OPT_OUT = 'opt-out';
690
+
675
691
  // Read at call time, not module scope: in production bundles the rollup replace plugin bakes
676
692
  // the URL in as a string literal either way, while in dev/tsx mode (and tests) this honors the
677
693
  // process env at the moment of the call.
@@ -688,13 +704,18 @@ const isKillSwitchEnabled = () => {
688
704
  const fetchTelemetryConfig = async logger => {
689
705
  if (isKillSwitchEnabled()) {
690
706
  logger.info('Telemetry disabled via CORVA_UI_MCP_TELEMETRY_DISABLED');
691
- return null;
707
+ return TELEMETRY_OPT_OUT;
692
708
  }
693
709
  const localConfig = await readLocalConfig(logger);
694
710
 
695
711
  // Explicit user opt-out ("enabled": false in the local file) — must stop here, not
696
712
  // fall through to the remote config, which would override the user's choice.
697
713
  if (localConfig === 'disabled') {
714
+ return TELEMETRY_OPT_OUT;
715
+ }
716
+
717
+ // A broken file fails closed too, but it is not the user's choice, so it is not opt-out.
718
+ if (localConfig === 'invalid') {
698
719
  return null;
699
720
  }
700
721
  if (localConfig) {
@@ -743,10 +764,18 @@ const fetchTelemetryConfig = async logger => {
743
764
  const initTelemetry = async logger => {
744
765
  try {
745
766
  const configResult = await fetchTelemetryConfig(logger);
767
+ if (configResult === TELEMETRY_OPT_OUT) {
768
+ return {
769
+ client: createNoopTelemetryClient(),
770
+ info: null,
771
+ disabledReason: 'opt-out'
772
+ };
773
+ }
746
774
  if (!configResult) {
747
775
  return {
748
776
  client: createNoopTelemetryClient(),
749
- info: null
777
+ info: null,
778
+ disabledReason: 'no-config'
750
779
  };
751
780
  }
752
781
  const {
@@ -777,13 +806,15 @@ const initTelemetry = async logger => {
777
806
  info: {
778
807
  configSource: source,
779
808
  samplingRate: config.samplingRate
780
- }
809
+ },
810
+ disabledReason: null
781
811
  };
782
812
  } catch (error) {
783
813
  logger.warn(`Telemetry init failed, continuing without telemetry: ${error}`);
784
814
  return {
785
815
  client: createNoopTelemetryClient(),
786
- info: null
816
+ info: null,
817
+ disabledReason: 'no-config'
787
818
  };
788
819
  }
789
820
  };
@@ -1037,7 +1068,7 @@ Do the following:
1037
1068
  2. Infer a sentiment ('positive', 'negative', or 'neutral') from the feedback. If it is ambiguous, ask the user briefly or default to 'neutral'.
1038
1069
  3. If the feedback is about a specific item, set category (component, hook, util, constant, client, theme, tool, docs, or other) and target (e.g. the component or hook name).
1039
1070
  4. Call \`mcp__corva-ui__submit_feedback\` with the message and the fields above, and set \`source: "user"\` (this feedback is relayed from the user).
1040
- 5. Confirm to the user that the feedback was sent.`;
1071
+ 5. Tell the user the delivery state the tool reported: queued, or not sent with its cause. Do not claim more than the tool reported.`;
1041
1072
  return {
1042
1073
  description: feedbackPromptDescription,
1043
1074
  messages: [{
@@ -1055,6 +1086,8 @@ const SEARCH_CONFIG = {
1055
1086
  exactMatch: 100,
1056
1087
  startsWith: 50,
1057
1088
  contains: 30,
1089
+ // Above keywordExact: a query word that names the entry beats one its keywords merely carry.
1090
+ nameWord: 25,
1058
1091
  keywordExact: 20,
1059
1092
  keywordContains: 10,
1060
1093
  description: 5,
@@ -1064,7 +1097,9 @@ const SEARCH_CONFIG = {
1064
1097
  v2Component: 10
1065
1098
  },
1066
1099
  defaults: {
1067
- maxResults: 10
1100
+ maxResults: 10,
1101
+ // Outside `limit`: the fallback answers "does the other version have it", not "show more".
1102
+ maxFallbackResults: 5
1068
1103
  }
1069
1104
  };
1070
1105
 
@@ -21548,6 +21583,35 @@ const withTimeout = async (promise, ms) => {
21548
21583
  */
21549
21584
  const singleLine = text => text.replace(/\s+/g, ' ').trim();
21550
21585
 
21586
+ const parseQuery = queryLower => {
21587
+ const rawWords = queryLower.trim().split(/\s+/);
21588
+ const queryWords = rawWords.filter(w => w.length > 1);
21589
+ const isMultiWord = rawWords.length >= 2 && queryWords.length >= 2;
21590
+ // When the query has filler words around a single meaningful word (e.g. "a button"),
21591
+ // the single-word path should match against just that word instead of the whole query.
21592
+ const singleWordQuery = !isMultiWord && queryWords.length === 1 ? queryWords[0] : queryLower;
21593
+ return {
21594
+ queryWords,
21595
+ isMultiWord,
21596
+ singleWordQuery
21597
+ };
21598
+ };
21599
+
21600
+ /**
21601
+ * Whether the name equals the query or starts with it — the query read the way the name scorers
21602
+ * read it: filler words dropped, a multi-word query collapsed ("date picker" → "datepicker").
21603
+ * Only such a match proves the version holds what was asked for; a keyword or prose hit does not.
21604
+ */
21605
+ const hasNameMatch = (name, query) => {
21606
+ const {
21607
+ queryWords,
21608
+ isMultiWord,
21609
+ singleWordQuery
21610
+ } = parseQuery(query.toLowerCase());
21611
+ const needle = (isMultiWord ? queryWords.join('') : singleWordQuery).trim();
21612
+ return needle.length > 0 && name.toLowerCase().startsWith(needle);
21613
+ };
21614
+
21551
21615
  /**
21552
21616
  * "icon" says which kind of result is wanted, not what it shows: against an icon it would match
21553
21617
  * every name, so "plus add icon" ranked arrows above PlusIcon. Other kinds keep the word —
@@ -21568,6 +21632,13 @@ const withoutIconStopWords = queryLower => queryLower.trim().split(/\s+/).filter
21568
21632
  */
21569
21633
  const isBuriedIcon = (entry, queryLower) => entry.type === 'icon' && entry.isDeprecated === true && entry.name.toLowerCase() !== withoutIconStopWords(queryLower);
21570
21634
 
21635
+ /**
21636
+ * Whole-word match for prose: a substring would let "table" hit "editable", and such weak matches
21637
+ * would keep the V1/V2 fallback from ever showing the real `Table`. `_` is a boundary because
21638
+ * keywords carry constant names like `asset_types`. Both arguments are lower case.
21639
+ */
21640
+ const includesWord = (textLower, word) => new RegExp(`(?<![a-z0-9])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![a-z0-9])`).test(textLower);
21641
+
21571
21642
  /**
21572
21643
  * The best a keyword list can do for ONE query word. The single-word branch of `handleSearch` used
21573
21644
  * to SUM over every keyword, so the score measured how long a keyword list is — `corvaAPI`'s 369
@@ -21581,12 +21652,12 @@ const scoreKeywordMatch = (keywords, word, weights) => keywords.reduce((best, ke
21581
21652
  }, 0);
21582
21653
 
21583
21654
  /**
21584
- * Split camelCase or PascalCase string into words
21585
- * e.g., "CustomGradient" -> ["custom", "gradient"]
21655
+ * The lower-case words of an entry name, for matching query words against. Acronym- and
21656
+ * `_`-aware — `NPT` → `npt`, `ASSET_TYPES` → `asset`, `types` — where the extractors'
21657
+ * `splitCamelCase` cuts at every capital and would leave such names no word to match. One-letter
21658
+ * pieces are dropped: a query word is never that short.
21586
21659
  */
21587
- const splitCamelCase = name => {
21588
- return name.replace(/([A-Z])/g, ' $1').toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
21589
- };
21660
+ const nameWords = name => (name.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+/g) ?? []).map(word => word.toLowerCase()).filter(word => word.length > 1);
21590
21661
 
21591
21662
  const scoreMultiWordQuery = (entry, queryWords, weights) => {
21592
21663
  let score = 0;
@@ -21605,7 +21676,7 @@ const scoreMultiWordQuery = (entry, queryWords, weights) => {
21605
21676
 
21606
21677
  // 2. All-words-in-name-parts matching
21607
21678
  let namePartsScore = 0;
21608
- const nameParts = splitCamelCase(entry.name).filter(part => part.length > 1);
21679
+ const nameParts = nameWords(entry.name);
21609
21680
  const allWordsInName = queryWords.every(word => nameParts.some(part => part === word));
21610
21681
  if (allWordsInName) {
21611
21682
  namePartsScore = weights.contains;
@@ -21614,19 +21685,19 @@ const scoreMultiWordQuery = (entry, queryWords, weights) => {
21614
21685
  // Take max of collapsed and name-parts
21615
21686
  score += Math.max(collapsedNameScore, namePartsScore);
21616
21687
 
21617
- // 3. Per-word keyword scoring
21688
+ // 3. Per-word scoring: the best keyword per word, never a sum — unless the word is a word of the
21689
+ // name, which outweighs any keyword. Otherwise "map layers toggle" scores LayersIcon like every
21690
+ // icon that only has "toggle" as a keyword, and file order decides.
21618
21691
  const descriptionLower = entry.description.toLowerCase();
21619
-
21620
- // Behaviour-identical to the inline loop this replaced: the best keyword per word, never a sum.
21621
- score += queryWords.reduce((keywordScore, word) => keywordScore + scoreKeywordMatch(entry.keywords, word, weights), 0);
21692
+ score += queryWords.reduce((wordScore, word) => wordScore + Math.max(nameParts.includes(word) ? weights.nameWord : 0, scoreKeywordMatch(entry.keywords, word, weights)), 0);
21622
21693
 
21623
21694
  // 4. All-words-in-description
21624
- if (queryWords.every(word => descriptionLower.includes(word))) {
21695
+ if (queryWords.every(word => includesWord(descriptionLower, word))) {
21625
21696
  score += weights.description;
21626
21697
  }
21627
21698
 
21628
21699
  // 5. All-words-in-searchText
21629
- if (queryWords.every(word => entry.searchText.includes(word))) {
21700
+ if (queryWords.every(word => includesWord(entry.searchText, word))) {
21630
21701
  score += weights.fullText;
21631
21702
  }
21632
21703
  return score;
@@ -21641,19 +21712,6 @@ const deprecationOrder = entry => entry.isDeprecated ? 1 : 0;
21641
21712
  * fell to generation order before, an accident of extractor discovery.
21642
21713
  */
21643
21714
  const compareScoredEntries = queryLower => (a, b) => Number(isBuriedIcon(a.entry, queryLower)) - Number(isBuriedIcon(b.entry, queryLower)) || b.score - a.score || deprecationOrder(a.entry) - deprecationOrder(b.entry);
21644
- const parseQuery = queryLower => {
21645
- const rawWords = queryLower.trim().split(/\s+/);
21646
- const queryWords = rawWords.filter(w => w.length > 1);
21647
- const isMultiWord = rawWords.length >= 2 && queryWords.length >= 2;
21648
- // When the query has filler words around a single meaningful word (e.g. "a button"),
21649
- // the single-word path should match against just that word instead of the whole query.
21650
- const singleWordQuery = !isMultiWord && queryWords.length === 1 ? queryWords[0] : queryLower;
21651
- return {
21652
- queryWords,
21653
- isMultiWord,
21654
- singleWordQuery
21655
- };
21656
- };
21657
21715
  const rankEntries = (entries, query) => {
21658
21716
  const {
21659
21717
  weights,
@@ -21690,10 +21748,10 @@ const rankEntries = (entries, query) => {
21690
21748
 
21691
21749
  // One word, one keyword payment — the same rule the multi-word branch applies per word.
21692
21750
  score += scoreKeywordMatch(entry.keywords, singleWordQuery, weights);
21693
- if (entry.description.toLowerCase().includes(singleWordQuery)) {
21751
+ if (includesWord(entry.description.toLowerCase(), singleWordQuery)) {
21694
21752
  score += weights.description;
21695
21753
  }
21696
- if (entry.searchText.includes(singleWordQuery)) {
21754
+ if (includesWord(entry.searchText, singleWordQuery)) {
21697
21755
  score += weights.fullText;
21698
21756
  }
21699
21757
  }
@@ -21748,12 +21806,12 @@ Examples:
21748
21806
  - "date picker" - find date/time components
21749
21807
  - "useSubscriptions" - find specific hook
21750
21808
 
21751
- When category is 'v2' or 'v1' and the requested version has no match, the response surfaces a labeled fallback block from the other version so a single search still discovers a candidate. The agent should still prefer V2 when both versions match.`;
21809
+ When category is 'v2' or 'v1', type is 'all' or 'component', and no component of the requested version is named by the query (its name equals or starts with it), up to 5 matching components of the other version follow the results, labeled, so a single search still discovers a candidate. The agent should still prefer V2 when both versions match.`;
21752
21810
  const searchToolSchema = {
21753
21811
  query: z.string().describe('Search query - component name, use case, or description'),
21754
21812
  type: z.enum(SEARCH_TYPES).optional().describe('Filter by type (default: all)'),
21755
21813
  category: z.enum(['all', 'v2', 'v1']).optional().describe('For components: v2 (modern) or v1 (default: all)'),
21756
- limit: z.number().optional().describe('Maximum results to return (default: 10)')
21814
+ limit: z.number().optional().describe('Maximum results to return (default: 10); a V1/V2 fallback adds up to 5 more')
21757
21815
  };
21758
21816
  const handleSearch = args => {
21759
21817
  const {
@@ -21784,7 +21842,10 @@ const handleSearch = args => {
21784
21842
  }
21785
21843
  const matchesRequested = entry => requestedCategory === null || entry.type !== COMPONENT_TYPE || entry.category === requestedCategory;
21786
21844
  const primary = ranked.filter(matchesRequested).slice(0, limit);
21787
- const fallback = requestedCategory && primary.length === 0 ? ranked.filter(entry => entry.type === COMPONENT_TYPE && entry.category !== requestedCategory).slice(0, limit) : [];
21845
+ // Weak matches — a keyword, a word of prose, an icon — do not prove the requested version has the
21846
+ // component, so they must not hide the other version's. Read before `limit` cuts the list.
21847
+ const requestedHasName = ranked.some(entry => entry.type === COMPONENT_TYPE && entry.category === requestedCategory && hasNameMatch(entry.name, query));
21848
+ const fallback = requestedCategory && (type === 'all' || type === COMPONENT_TYPE) && !requestedHasName ? ranked.filter(entry => entry.type === COMPONENT_TYPE && entry.category !== requestedCategory).slice(0, SEARCH_CONFIG.defaults.maxFallbackResults) : [];
21788
21849
  if (primary.length === 0 && fallback.length === 0) {
21789
21850
  return {
21790
21851
  response: createToolResponse(`No results found for ${echoedQuery}. Try:\n- A different search term\n- Using list_corva_ui to see available items`),
@@ -21801,20 +21862,24 @@ const handleSearch = args => {
21801
21862
  const fields = [notice, singleLine(r.description) || NO_DESCRIPTION, ...importLines(r)];
21802
21863
  return [`**${r.name}**${categoryLabel} - ${r.type}`, ...fields.filter(Boolean).map(field => `${INDENT}${field}`)].join('\n');
21803
21864
  };
21804
- if (primary.length > 0) {
21805
- const formatted = primary.map(formatEntry).join('\n\n');
21865
+ const formatted = [...primary, ...fallback].map(formatEntry).join('\n\n');
21866
+ const resultCount = primary.length + fallback.length;
21867
+ if (fallback.length === 0) {
21806
21868
  return {
21807
21869
  response: createToolResponse(`Found ${primary.length} result(s) for ${echoedQuery}:\n\n${formatted}`),
21808
- resultCount: primary.length
21870
+ resultCount
21809
21871
  };
21810
21872
  }
21811
21873
  const requestedVersion = requestedCategory.replace(COMPONENTS_CATEGORY_PREFIX, '').toUpperCase();
21812
21874
  const fallbackVersion = requestedVersion === 'V2' ? 'V1' : 'V2';
21813
21875
  const preferenceHint = requestedVersion === 'V2' ? ' V2 is preferred when available; consider these only if no V2 alternative exists.' : '';
21814
- const formatted = fallback.map(formatEntry).join('\n\n');
21876
+ // The label lives in the preamble, not in a block of its own: the blank line separates results
21877
+ // and nothing else, so a consumer can still count and split them. Each fallback block carries
21878
+ // its version in its heading.
21879
+ const preamble = primary.length === 0 ? `No ${requestedVersion} matches for ${echoedQuery}. ${fallbackVersion} fallback — ${fallbackVersion} components that match the query.` : `Found ${primary.length} result(s) for ${echoedQuery}, but no ${requestedVersion} component name equals or starts with it. The last ${fallback.length} result(s) are the ${fallbackVersion} fallback — ${fallbackVersion} components that match the query.`;
21815
21880
  return {
21816
- response: createToolResponse(`No ${requestedVersion} matches for ${echoedQuery}. ${fallbackVersion} fallback — ${fallbackVersion} components that match the query.${preferenceHint}\n\n${formatted}`),
21817
- resultCount: fallback.length
21881
+ response: createToolResponse(`${preamble}${preferenceHint}\n\n${formatted}`),
21882
+ resultCount
21818
21883
  };
21819
21884
  };
21820
21885
 
@@ -22437,11 +22502,54 @@ ${formatMethods(client.methods)}`;
22437
22502
  };
22438
22503
  };
22439
22504
 
22505
+ const FEEDBACK_CATEGORIES = [...ENTITY_TYPES, 'theme', 'tool', 'docs', 'other'];
22506
+ const submitFeedbackToolName = 'submit_feedback';
22507
+ const submitFeedbackToolTitle = 'Submit Feedback';
22508
+ const submitFeedbackToolDescription = `Send feedback about @corva/ui or this MCP server so the maintainers can improve docs and coverage.
22509
+ Use this when the user says something like "send feedback that…", or proactively when you hit a docs/coverage gap (a missing component, a wrong or incomplete prop, a hook that lacks an example, a search that should have matched but didn't).
22510
+
22511
+ Always infer sentiment and category from the feedback itself — do not leave them at the defaults. Reserve sentiment 'neutral' for genuinely factual/mixed feedback and category 'other' only when none of the specific categories fit.
22512
+ Set source to 'agent' when you are reporting a gap on your own initiative, or 'user' when relaying something the user said.
22513
+ Keep message specific and actionable; name the component/hook/util in target when the feedback is about a particular item.
22514
+ The response states whether the message was queued or not sent, and why; relay that state to the user as is.`;
22515
+ const submitFeedbackToolSchema = {
22516
+ message: z.string().min(1).describe('The feedback text (required). Be specific and actionable.'),
22517
+ sentiment: z.enum(['positive', 'negative', 'neutral']).optional().describe("Tone of the feedback — classify it from the message; don't default to neutral unless the feedback is genuinely neutral/factual (default: neutral)"),
22518
+ category: z.enum(FEEDBACK_CATEGORIES).optional().describe("What the feedback is about — pick the most specific category; use 'other' only when none apply"),
22519
+ target: z.string().optional().describe('The specific component/hook/util/tool the feedback is about (e.g. "Button")'),
22520
+ source: z.enum(['user', 'agent']).optional().describe("Who originated the feedback: 'user' (relayed) or 'agent' (default: user)")
22521
+ };
22522
+ const NOT_SENT_CAUSES = {
22523
+ 'opt-out': 'telemetry is turned off by opt-out (CORVA_UI_MCP_TELEMETRY_DISABLED, or "enabled": false in the local telemetry config)',
22524
+ 'no-config': 'telemetry has no valid configuration in this session, or it failed to start'
22525
+ };
22526
+ const describeFeedbackDelivery = delivery => delivery.state === 'queued' ? 'queued — handed to telemetry for export; delivery is not confirmed' : `not sent — ${NOT_SENT_CAUSES[delivery.cause]}`;
22527
+ const formatResponse = (delivery, sentiment) => delivery.state === 'queued' ? `Feedback ${describeFeedbackDelivery(delivery)}. (${sentiment})\nThe @corva/ui team uses this to improve the library and its docs.` : `Feedback ${describeFeedbackDelivery(delivery)}. Feedback travels only through telemetry, so the @corva/ui team will not receive this message. Tell the user, so they can share it with the team another way.`;
22528
+ const handleSubmitFeedback = (args, delivery) => {
22529
+ const message = args.message?.trim() ?? '';
22530
+ if (!message) {
22531
+ throw new Error('Feedback message cannot be empty.');
22532
+ }
22533
+ const sentiment = args.sentiment ?? 'neutral';
22534
+ const source = args.source ?? 'user';
22535
+ const category = args.category?.trim() || undefined;
22536
+ const target = args.target?.trim() || undefined;
22537
+ return {
22538
+ response: createToolResponse(formatResponse(delivery, sentiment)),
22539
+ delivery,
22540
+ message,
22541
+ sentiment,
22542
+ category,
22543
+ target,
22544
+ source
22545
+ };
22546
+ };
22547
+
22440
22548
  const diagnosticsToolName = 'get_diagnostics';
22441
22549
  const diagnosticsToolTitle = 'Get Server Diagnostics';
22442
22550
  const diagnosticsToolDescription = `Get MCP server health metrics.
22443
22551
  Returns server version, uptime, memory usage, request statistics, telemetry status,
22444
- project identity (app key and package name), and the prompts this server registers.`;
22552
+ whether feedback can reach the maintainers, project identity (app key and package name), and the prompts this server registers.`;
22445
22553
  const diagnosticsToolSchema = {};
22446
22554
  const formatUptime = ms => {
22447
22555
  const seconds = Math.floor(ms / 1000);
@@ -22500,6 +22608,9 @@ const handleGetDiagnostics = (stats, telemetry, identity, registeredPromptNames)
22500
22608
  text += `${bold('Telemetry sampling rate')}: ${telemetry.samplingRate}\n`;
22501
22609
  }
22502
22610
  }
22611
+ if (telemetry.feedbackDelivery) {
22612
+ text += `${bold('Feedback channel')}: ${describeFeedbackDelivery(telemetry.feedbackDelivery)}\n`;
22613
+ }
22503
22614
  } else {
22504
22615
  text += `${bold('Status')}: Disabled\n`;
22505
22616
  }
@@ -22516,41 +22627,6 @@ const handleGetDiagnostics = (stats, telemetry, identity, registeredPromptNames)
22516
22627
  };
22517
22628
  };
22518
22629
 
22519
- const FEEDBACK_CATEGORIES = [...ENTITY_TYPES, 'theme', 'tool', 'docs', 'other'];
22520
- const submitFeedbackToolName = 'submit_feedback';
22521
- const submitFeedbackToolTitle = 'Submit Feedback';
22522
- const submitFeedbackToolDescription = `Send feedback about @corva/ui or this MCP server so the maintainers can improve docs and coverage.
22523
- Use this when the user says something like "send feedback that…", or proactively when you hit a docs/coverage gap (a missing component, a wrong or incomplete prop, a hook that lacks an example, a search that should have matched but didn't).
22524
-
22525
- Always infer sentiment and category from the feedback itself — do not leave them at the defaults. Reserve sentiment 'neutral' for genuinely factual/mixed feedback and category 'other' only when none of the specific categories fit.
22526
- Set source to 'agent' when you are reporting a gap on your own initiative, or 'user' when relaying something the user said.
22527
- Keep message specific and actionable; name the component/hook/util in target when the feedback is about a particular item.`;
22528
- const submitFeedbackToolSchema = {
22529
- message: z.string().min(1).describe('The feedback text (required). Be specific and actionable.'),
22530
- sentiment: z.enum(['positive', 'negative', 'neutral']).optional().describe("Tone of the feedback — classify it from the message; don't default to neutral unless the feedback is genuinely neutral/factual (default: neutral)"),
22531
- category: z.enum(FEEDBACK_CATEGORIES).optional().describe("What the feedback is about — pick the most specific category; use 'other' only when none apply"),
22532
- target: z.string().optional().describe('The specific component/hook/util/tool the feedback is about (e.g. "Button")'),
22533
- source: z.enum(['user', 'agent']).optional().describe("Who originated the feedback: 'user' (relayed) or 'agent' (default: user)")
22534
- };
22535
- const handleSubmitFeedback = args => {
22536
- const message = args.message?.trim() ?? '';
22537
- if (!message) {
22538
- throw new Error('Feedback message cannot be empty.');
22539
- }
22540
- const sentiment = args.sentiment ?? 'neutral';
22541
- const source = args.source ?? 'user';
22542
- const category = args.category?.trim() || undefined;
22543
- const target = args.target?.trim() || undefined;
22544
- return {
22545
- response: createToolResponse(`Thanks — your feedback was recorded. (${sentiment})\nThe @corva/ui team uses this to improve the library and its docs.`),
22546
- message,
22547
- sentiment,
22548
- category,
22549
- target,
22550
- source
22551
- };
22552
- };
22553
-
22554
22630
  /**
22555
22631
  * Authoring types for inline component migrations.
22556
22632
  *
@@ -23004,7 +23080,8 @@ const registerTools = deps => {
23004
23080
  getStats,
23005
23081
  getTelemetryStatus,
23006
23082
  getProjectIdentity,
23007
- getTelemetry
23083
+ getTelemetry,
23084
+ getFeedbackDelivery
23008
23085
  } = deps;
23009
23086
 
23010
23087
  // TODO: every `inputSchema: …ToolSchema as any` below is unexplained — the cast predates any
@@ -23181,7 +23258,7 @@ const registerTools = deps => {
23181
23258
  const parentContext = extractContextFromMeta(extra?._meta);
23182
23259
  try {
23183
23260
  return executeToolWithObservability(submitFeedbackToolName, args, () => {
23184
- const result = handleSubmitFeedback(args);
23261
+ const result = handleSubmitFeedback(args, getFeedbackDelivery());
23185
23262
  // Supplemental low-cardinality counter, in addition to the standard
23186
23263
  // tool-call span/metrics recorded by the wrapper. No-op when disabled.
23187
23264
  const telemetry = getTelemetry();
@@ -23192,7 +23269,7 @@ const registerTools = deps => {
23192
23269
  ...result,
23193
23270
  isEmpty: false
23194
23271
  };
23195
- }, r => `feedback recorded (${r.sentiment})`, {
23272
+ }, r => `feedback ${r.delivery.state} (${r.sentiment})`, {
23196
23273
  parentContext,
23197
23274
  getSpanAttributes: r => ({
23198
23275
  // Indexed copy of the message so it is searchable/groupable in
@@ -23305,6 +23382,7 @@ class CorvaUiMcpServer {
23305
23382
 
23306
23383
  telemetry = createNoopTelemetryClient();
23307
23384
  telemetryInfo = null;
23385
+ telemetryDisabledReason = null;
23308
23386
  telemetryInitPromise = null;
23309
23387
  shutdownRequested = false;
23310
23388
  pendingInitData = null;
@@ -23528,7 +23606,8 @@ class CorvaUiMcpServer {
23528
23606
  getStats: () => this.getStats(),
23529
23607
  getTelemetryStatus: () => this.getTelemetryStatus(),
23530
23608
  getProjectIdentity: () => this.projectIdentity,
23531
- getTelemetry: () => this.telemetry
23609
+ getTelemetry: () => this.telemetry,
23610
+ getFeedbackDelivery: () => this.getFeedbackDelivery()
23532
23611
  });
23533
23612
  }
23534
23613
  setupPrompts() {
@@ -23546,10 +23625,12 @@ class CorvaUiMcpServer {
23546
23625
  const telemetryTimer = this.mcpLogger.time('telemetry-init');
23547
23626
  this.telemetryInitPromise = initTelemetry(this.mcpLogger).then(({
23548
23627
  client,
23549
- info
23628
+ info,
23629
+ disabledReason
23550
23630
  }) => {
23551
23631
  this.telemetry = client;
23552
23632
  this.telemetryInfo = info;
23633
+ this.telemetryDisabledReason = disabledReason;
23553
23634
  }).catch(() => {
23554
23635
  /* telemetry is best-effort — it must never take the server down */
23555
23636
  });
@@ -23601,7 +23682,16 @@ class CorvaUiMcpServer {
23601
23682
  enabled: this.telemetry.isEnabled(),
23602
23683
  sessionId: this.telemetry.isEnabled() ? this.telemetry.sessionId : undefined,
23603
23684
  configSource: this.telemetryInfo?.configSource,
23604
- samplingRate: this.telemetryInfo?.samplingRate
23685
+ samplingRate: this.telemetryInfo?.samplingRate,
23686
+ feedbackDelivery: this.getFeedbackDelivery()
23687
+ };
23688
+ }
23689
+ getFeedbackDelivery() {
23690
+ return this.telemetry.isEnabled() ? {
23691
+ state: 'queued'
23692
+ } : {
23693
+ state: 'not-sent',
23694
+ cause: this.telemetryDisabledReason ?? 'no-config'
23605
23695
  };
23606
23696
  }
23607
23697
  getStats() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@corva/ui",
3
- "version": "3.78.0-15",
3
+ "version": "3.78.0-17",
4
4
  "license": "UNLICENSED",
5
5
  "description": "Shared components/utils for Corva ui projects",
6
6
  "repository": {