@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 +11 -3
- package/mcp-server/server.mjs +180 -90
- package/package.json +1 -1
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.
|
|
715
|
-
|
|
716
|
-
|
|
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
|
|
package/mcp-server/server.mjs
CHANGED
|
@@ -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 {
|
|
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.
|
|
249
|
+
const MCP_SERVER_VERSION = '1.9.0';
|
|
250
250
|
|
|
251
|
-
var version = "3.78.0-
|
|
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:
|
|
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 '
|
|
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 '
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
21585
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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()
|
|
21751
|
+
if (includesWord(entry.description.toLowerCase(), singleWordQuery)) {
|
|
21694
21752
|
score += weights.description;
|
|
21695
21753
|
}
|
|
21696
|
-
if (entry.searchText
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
21805
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
21817
|
-
resultCount
|
|
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
|
|
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() {
|