Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions src/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@ export interface ClientInfo {
version: string;
}

export function normalizeTelemetryEnabledValue(value: unknown): unknown {
if (typeof value !== 'string') {
return value;
}

const normalized = value.trim().toLowerCase();
if (normalized === 'true') {
return true;
}

if (normalized === 'false') {
return false;
}

return value;
}

export function isTelemetryDisabledValue(value: unknown): boolean {
return normalizeTelemetryEnabledValue(value) === false;
}

/**
* Singleton config manager for the server
*/
Expand Down Expand Up @@ -185,14 +206,19 @@ class ConfigManager {
*/
async setValue(key: string, value: any): Promise<void> {
await this.init();

if (key === 'telemetryEnabled') {
value = normalizeTelemetryEnabledValue(value);
}

// Special handling for telemetry opt-out
if (key === 'telemetryEnabled' && value === false) {
if (key === 'telemetryEnabled' && isTelemetryDisabledValue(value)) {
// Get the current value before changing it
const currentValue = this.config[key];
const currentValue: unknown = this.config[key];
const telemetryAlreadyDisabled = isTelemetryDisabledValue(currentValue);

// Only capture the opt-out event if telemetry was previously enabled
if (currentValue !== false) {
if (!telemetryAlreadyDisabled) {
// Import the capture function dynamically to avoid circular dependencies
const { capture } = await import('./utils/capture.js');

Expand Down Expand Up @@ -251,4 +277,4 @@ class ConfigManager {
}

// Export singleton instance
export const configManager = new ConfigManager();
export const configManager = new ConfigManager();
1 change: 1 addition & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)

if (name === 'set_config_value' && args && typeof args === 'object' && 'key' in args) {
telemetryData.set_config_value_key_name = (args as any).key;
telemetryData.call_origin = (args as any).origin === 'ui' ? 'ui' : 'llm';
}
if (name === 'get_prompts' && args && typeof args === 'object') {
const promptArgs = args as any;
Expand Down
22 changes: 22 additions & 0 deletions src/tools/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,28 @@ export async function setConfigValue(args: unknown) {
}
}

// Harden boolean fields against stringly-typed inputs like "false".
if (fieldDefinition.valueType === 'boolean') {
if (typeof valueToStore === 'string') {
const normalized = valueToStore.trim().toLowerCase();
if (normalized === 'true') {
valueToStore = true;
} else if (normalized === 'false') {
valueToStore = false;
}
}

if (typeof valueToStore !== 'boolean') {
return {
content: [{
type: "text",
text: `Value for ${parsed.data.key} must be boolean true/false.`
}],
isError: true
};
}
}

await configManager.setValue(parsed.data.key, valueToStore);
// Get the updated configuration to show the user
const updatedConfig = await configManager.getConfig();
Expand Down
1 change: 1 addition & 0 deletions src/tools/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const SetConfigValueArgsSchema = z.object({
z.array(z.string()),
z.null(),
]),
origin: z.enum(['ui', 'llm']).optional(),
});

// Empty schemas
Expand Down
143 changes: 120 additions & 23 deletions src/ui/config-editor/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { renderCompactRow } from '../../shared/compact-row.js';
import { escapeHtml } from '../../shared/escape-html.js';
import { createWidgetStateStorage } from '../../shared/widget-state.js';
import { connectWithSharedHostContext, isObjectRecord, type UiChromeState } from '../../shared/host-context.js';
import { createUiEventTracker, type UiEventParams } from '../../shared/ui-event-tracker.js';
import { createArrayModalController, renderArrayModalMarkup } from './array-modal.js';
import { CONFIG_FIELD_DEFINITIONS, isConfigFieldKey } from '../../../config-field-definitions.js';

Expand Down Expand Up @@ -46,12 +47,57 @@ export interface ApplyConfigResult {
interface RenderHooks {
onConfigChanged?: (change: { key: string; value: unknown }) => void;
onTooltip?: (tooltip: TooltipMessage) => void;
onExpandedChanged?: (expanded: boolean) => void;
}

type ToolCall = (name: string, args?: Record<string, unknown>) => Promise<unknown>;
type TrackConfigUiEvent = (event: string, params?: Record<string, unknown>) => void;

let shellController: ToolShellController | undefined;

const CONFIG_EDITOR_COMPONENT = 'config_editor';
const GET_CONFIG_TOOL_NAME = 'get_config';
const MAX_TELEMETRY_MESSAGE_LENGTH = 180;

function sanitizeTelemetryErrorMessage(message: string): string {
// Keep error signal useful while removing path-like data and bounding payload size.
const collapsed = message.replace(/\s+/g, ' ').trim();
const withoutPaths = collapsed
.replace(/(?:\/|\\)[\w\d_.\-/\\]+/g, '[PATH]')
.replace(/[A-Za-z]:\\[\w\d_.\-/\\]+/g, '[PATH]');

if (withoutPaths.length === 0) {
return 'Unknown error';
}

if (withoutPaths.length <= MAX_TELEMETRY_MESSAGE_LENGTH) {
return withoutPaths;
}

return `${withoutPaths.slice(0, MAX_TELEMETRY_MESSAGE_LENGTH - 3)}...`;
}

function buildConfigUpdateTelemetryParams(args: {
configKey: string;
valueType: string;
errorMessage?: string;
errorStage?: 'set_config_value' | 'transport';
}): UiEventParams {
const base: UiEventParams = {
config_key: args.configKey,
value_type: args.valueType,
};

if (args.errorStage) {
base.error_stage = args.errorStage;
}
if (args.errorMessage) {
base.error_message = sanitizeTelemetryErrorMessage(args.errorMessage);
}

return base;
}

function isConfigEditorPayload(value: unknown): value is ConfigEditorPayload {
return isObjectRecord(value) && Array.isArray((value as Record<string, unknown>).entries);
}
Expand Down Expand Up @@ -310,7 +356,7 @@ function getShellOptions(payload: ConfigEditorPayload | null, currentShell: stri
return [...options];
}

export function createConfigEditorController(callTool: ToolCall) {
export function createConfigEditorController(callTool: ToolCall, trackConfigUiEvent?: TrackConfigUiEvent) {
const state: ConfigEditorState = {
payload: null,
selectedKey: null,
Expand Down Expand Up @@ -392,23 +438,45 @@ export function createConfigEditorController(callTool: ToolCall) {
const setResult = await callTool('set_config_value', {
key: selected.key,
value: parsed.value,
origin: 'ui',
});

if (isToolErrorResult(setResult)) {
const errorMessage = extractToolText(setResult) ?? `Failed to update ${selected.key}.`;
trackConfigUiEvent?.('config_update_failed', {
tool_name: 'set_config_value',
...buildConfigUpdateTelemetryParams({
configKey: selected.key,
valueType: selected.valueType,
errorMessage,
errorStage: 'set_config_value',
}),
});

return {
ok: false,
tooltip: {
message: extractToolText(setResult) ?? `Failed to update ${selected.key}.`,
message: errorMessage,
tone: 'error',
},
};
}

trackConfigUiEvent?.('config_update_success', {
tool_name: 'set_config_value',
...buildConfigUpdateTelemetryParams({
configKey: selected.key,
valueType: selected.valueType,
}),
});

const refreshed = await callTool('get_config', {});
if (isToolErrorResult(refreshed)) {
const errorMessage = extractToolText(refreshed) ?? 'Value was updated but config refresh failed.';
return {
ok: false,
tooltip: {
message: extractToolText(refreshed) ?? 'Value was updated but config refresh failed.',
message: errorMessage,
tone: 'error',
},
};
Expand All @@ -425,12 +493,25 @@ export function createConfigEditorController(callTool: ToolCall) {
}
}

return { ok: true };
return {
ok: true,
};
} catch (error) {
const errorMessage = `Failed to apply value: ${error instanceof Error ? error.message : String(error)}`;
trackConfigUiEvent?.('config_update_failed', {
tool_name: 'set_config_value',
...buildConfigUpdateTelemetryParams({
configKey: selected.key,
valueType: selected.valueType,
errorMessage,
errorStage: 'transport',
}),
});

return {
ok: false,
tooltip: {
message: `Failed to apply value: ${error instanceof Error ? error.message : String(error)}`,
message: errorMessage,
tone: 'error',
},
};
Expand Down Expand Up @@ -712,6 +793,7 @@ function render(container: HTMLElement, controller: ReturnType<typeof createConf
initialExpanded: chrome.expanded,
onToggle: (expanded) => {
chrome.expanded = expanded;
hooks.onExpandedChanged?.(expanded);
},
});
}
Expand All @@ -727,14 +809,26 @@ export function bootstrapConfigEditorApp(): void {
}

const bridge = createToolBridge();
const controller = createConfigEditorController((name, args) => bridge.callTool(name, args));
const trackConfigUiEvent = createUiEventTracker(
(name, args) => bridge.callTool(name, args),
{
component: CONFIG_EDITOR_COMPONENT,
baseParams: { origin: 'ui' },
}
);
const controller = createConfigEditorController(
(name, args) => bridge.callTool(name, args),
trackConfigUiEvent
);
const widgetState = createWidgetStateStorage<ConfigEditorPayload>(isConfigEditorPayload);
const chrome: UiChromeState = {
hideSummaryRow: false,
compact: false,
expanded: true,
};

let configEditorShownEventSent = false;

let quietContextSupported = true;
let tooltipHideTimer: number | null = null;

Expand Down Expand Up @@ -765,28 +859,16 @@ export function bootstrapConfigEditorApp(): void {
};

const syncModelContext = (reason: string, change?: { key: string; value: unknown }): void => {
const payload = controller.state.payload;
if (!payload || !quietContextSupported) {
if (!quietContextSupported || !change) {
return;
}
const values = payload.entries
.map((entry) => `${entry.key}=${JSON.stringify(entry.value)}`)
.join(', ');
const changeText = change
? `Updated ${change.key} to ${JSON.stringify(change.value)}.`
: 'Configuration updated.';

app.updateModelContext({
content: [{ type: 'text', text: `${changeText} Snapshot (${reason}): ${values}` }],
content: [{ type: 'text', text: `Updated ${change.key} to ${JSON.stringify(change.value)} (${reason}).` }],
structuredContent: {
reason,
changedKey: change?.key,
changedValue: change?.value,
entries: payload.entries.map((entry) => ({
key: entry.key,
label: entry.label,
value: entry.value,
valueType: entry.valueType,
})),
changedKey: change.key,
changedValue: change.value,
},
}).catch(() => {
// Host may not support updateModelContext; avoid repeated failed calls.
Expand All @@ -809,6 +891,12 @@ export function bootstrapConfigEditorApp(): void {
syncModelContext('widget-edit', change);
},
onTooltip: showTooltip,
onExpandedChanged: (expanded) => {
trackConfigUiEvent(expanded ? 'expand' : 'collapse', {
tool_name: GET_CONFIG_TOOL_NAME,
expanded,
});
},
});
markReady();
});
Expand Down Expand Up @@ -836,6 +924,15 @@ export function bootstrapConfigEditorApp(): void {
controller.setPayload(payload);
widgetState.write(payload);
scheduleRender();

if (!configEditorShownEventSent) {
configEditorShownEventSent = true;
// One-shot impression event for get_config UI card visibility.
trackConfigUiEvent('config_editor_shown', {
tool_name: GET_CONFIG_TOOL_NAME,
entry_count: payload.entries.length,
});
}
};

const refreshConfigFromServer = async (): Promise<void> => {
Expand Down
13 changes: 7 additions & 6 deletions src/ui/file-preview/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createCompactRowShellController, type ToolShellController } from '../..
import { createWidgetStateStorage } from '../../shared/widget-state.js';
import { renderCompactRow } from '../../shared/compact-row.js';
import { connectWithSharedHostContext, isObjectRecord, type UiChromeState } from '../../shared/host-context.js';
import { createUiEventTracker } from '../../shared/ui-event-tracker.js';
import { App } from '@modelcontextprotocol/ext-apps';

let isExpanded = false;
Expand Down Expand Up @@ -761,13 +762,13 @@ export function bootstrapApp(): void {
});
};

trackUiEvent = (event: string, params: Record<string, unknown> = {}): void => {
void rpcCallTool?.('track_ui_event', {
event,
trackUiEvent = createUiEventTracker(
(name, args) => app.callServerTool({ name, arguments: args }),
{
component: 'file_preview',
params: { tool_name: 'read_file', ...params }
}).catch(() => {});
};
baseParams: { tool_name: 'read_file' },
}
);

// Register ALL handlers BEFORE connect
app.onteardown = async () => {
Expand Down
Loading