From f7637a01ffebabeef79aef251ff80c86380a5f77 Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Sat, 27 Sep 2025 13:40:35 -0700 Subject: [PATCH 01/13] feat(shared): add detail level enums and optional text/css fields to TargetedElement --- packages/shared/src/detail.ts | 76 +++++++++++++++++++++++++++++++++++ packages/shared/src/types.ts | 30 +++++++++----- 2 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 packages/shared/src/detail.ts diff --git a/packages/shared/src/detail.ts b/packages/shared/src/detail.ts new file mode 100644 index 0000000..91fce09 --- /dev/null +++ b/packages/shared/src/detail.ts @@ -0,0 +1,76 @@ +import { CSSDetailLevel, TextDetailLevel } from './types'; + +export const TEXT_DETAIL_OPTIONS: readonly TextDetailLevel[] = ['full', 'visible', 'none']; + +export const CSS_DETAIL_OPTIONS: readonly CSSDetailLevel[] = [0, 1, 2, 3]; + +export const CSS_LEVEL_1_FIELDS: readonly string[] = [ + 'display', + 'position', + 'fontSize', + 'color', + 'backgroundColor', +]; + +const CSS_LEVEL_2_EXTRA_FIELDS = [ + 'margin', + 'marginTop', + 'marginRight', + 'marginBottom', + 'marginLeft', + 'padding', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'lineHeight', + 'textAlign', + 'fontWeight', + 'fontFamily', + 'width', + 'height', + 'minWidth', + 'maxWidth', + 'minHeight', + 'maxHeight', + 'border', + 'borderTop', + 'borderRight', + 'borderBottom', + 'borderLeft', + 'borderRadius', + 'borderTopLeftRadius', + 'borderTopRightRadius', + 'borderBottomRightRadius', + 'borderBottomLeftRadius', + 'boxSizing', + 'flexDirection', + 'justifyContent', + 'alignItems', + 'gap', + 'overflow', + 'overflowX', + 'overflowY', +] as const; + +export const CSS_LEVEL_2_FIELDS: readonly string[] = Object.freeze([ + ...CSS_LEVEL_1_FIELDS, + ...CSS_LEVEL_2_EXTRA_FIELDS, +]); + +export const CSS_LEVEL_FIELD_MAP: Record< +Exclude, +readonly string[] +> = Object.freeze({ + 1: CSS_LEVEL_1_FIELDS, + 2: CSS_LEVEL_2_FIELDS, + 3: [], +}); + +export function isValidTextDetail(detail: unknown): detail is TextDetailLevel { + return typeof detail === 'string' && (TEXT_DETAIL_OPTIONS as readonly string[]).includes(detail); +} + +export function isValidCSSLevel(level: unknown): level is CSSDetailLevel { + return typeof level === 'number' && (CSS_DETAIL_OPTIONS as readonly number[]).includes(level); +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index f64d09e..fe9cca4 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1,3 +1,16 @@ +export type TextDetailLevel = 'full' | 'visible' | 'none'; + +export type CSSDetailLevel = 0 | 1 | 2 | 3; + +export const DEFAULT_TEXT_DETAIL: TextDetailLevel = 'full'; + +export const DEFAULT_CSS_LEVEL: CSSDetailLevel = 1; + +export interface TextSnapshots { + visible: string; + full: string; +} + export interface ElementPosition { x: number; y: number; @@ -5,13 +18,7 @@ export interface ElementPosition { height: number; } -export interface CSSProperties { - display: string; - position: string; - fontSize: string; - color: string; - backgroundColor: string; -} +export type CSSProperties = Record; export interface ComponentInfo { name?: string; @@ -24,10 +31,15 @@ export interface TargetedElement { tagName: string; id?: string; classes: string[]; - innerText: string; + innerText?: string; + textContent?: string; + textDetail?: TextDetailLevel; + textVariants?: TextSnapshots; attributes: Record; position: ElementPosition; - cssProperties: CSSProperties; + cssLevel?: CSSDetailLevel; + cssProperties?: CSSProperties; + cssComputed?: Record; componentInfo?: ComponentInfo; timestamp: number; url: string; From 4860ac2454c0e2f55e6bd3c4c7b802921927648c Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Sat, 27 Sep 2025 13:43:24 -0700 Subject: [PATCH 02/13] feat(extension): capture text variants and full computed styles in element serializer --- .../chrome-extension/src/utils/element.ts | 160 +++++++++++++++--- packages/chrome-extension/src/utils/types.ts | 8 +- 2 files changed, 147 insertions(+), 21 deletions(-) diff --git a/packages/chrome-extension/src/utils/element.ts b/packages/chrome-extension/src/utils/element.ts index 2d291f4..2d1e987 100644 --- a/packages/chrome-extension/src/utils/element.ts +++ b/packages/chrome-extension/src/utils/element.ts @@ -2,8 +2,18 @@ /* eslint-disable no-underscore-dangle */ import { - ComponentInfo, CSSProperties, ElementPosition, TargetedElement, RawPointedDOMElement, + ComponentInfo, + CSSDetailLevel, + CSSProperties, + DEFAULT_CSS_LEVEL, + DEFAULT_TEXT_DETAIL, + ElementPosition, + TargetedElement, + TextDetailLevel, + TextSnapshots, + RawPointedDOMElement, } from '@mcp-pointer/shared/types'; +import { CSS_LEVEL_FIELD_MAP } from '@mcp-pointer/shared/detail'; import logger from './logger'; export interface ReactSourceInfo { @@ -12,6 +22,105 @@ export interface ReactSourceInfo { columnNumber?: number; } +export interface ElementSerializationOptions { + textDetail?: TextDetailLevel; + cssLevel?: CSSDetailLevel; +} + +function toKebabCase(property: string): string { + return property + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/_/g, '-') + .toLowerCase(); +} + +function toCamelCase(property: string): string { + return property + .replace(/^-+/, '') + .replace(/-([a-z])/g, (_, char: string) => char.toUpperCase()); +} + +function getStyleValue(style: CSSStyleDeclaration, property: string): string | undefined { + const camelValue = (style as any)[property]; + if (typeof camelValue === 'string' && camelValue.trim().length > 0) { + return camelValue; + } + + const kebab = toKebabCase(property); + const value = style.getPropertyValue(kebab); + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + + return undefined; +} + +function extractFullCSSProperties(style: CSSStyleDeclaration): Record { + const properties: Record = {}; + + for (let i = 0; i < style.length; i += 1) { + const property = style.item(i); + + if (property && !property.startsWith('-')) { + const value = style.getPropertyValue(property); + if (typeof value === 'string' && value.trim().length > 0) { + const camel = toCamelCase(property); + properties[camel] = value; + } + } + } + + return properties; +} + +function getElementCSSProperties( + style: CSSStyleDeclaration, + cssLevel: CSSDetailLevel, + fullCSS: Record, +): CSSProperties | undefined { + if (cssLevel === 0) { + return undefined; + } + + if (cssLevel === 3) { + return fullCSS; + } + + const fields = CSS_LEVEL_FIELD_MAP[cssLevel]; + const properties: CSSProperties = {}; + + fields.forEach((property) => { + const value = getStyleValue(style, property); + if (value !== undefined) { + properties[property] = value; + } + }); + + return properties; +} + +function collectTextVariants(element: HTMLElement): TextSnapshots { + const visible = element.innerText || ''; + const full = element.textContent || visible; + + return { + visible, + full, + }; +} + +function resolveTextByDetail(variants: TextSnapshots, detail: TextDetailLevel): string | undefined { + if (detail === 'none') { + return undefined; + } + + if (detail === 'visible') { + return variants.visible; + } + + return variants.full || variants.visible; +} + /** * Get source file information from a DOM element's React component */ @@ -172,20 +281,6 @@ export function getElementPosition(element: HTMLElement): ElementPosition { }; } -/** - * Extract relevant CSS properties from an element - */ -export function getElementCSSProperties(element: HTMLElement): CSSProperties { - const computedStyle = window.getComputedStyle(element); - return { - display: computedStyle.display, - position: computedStyle.position, - fontSize: computedStyle.fontSize, - color: computedStyle.color, - backgroundColor: computedStyle.backgroundColor, - }; -} - /** * Extract CSS classes from an element as an array */ @@ -197,20 +292,47 @@ export function getElementClasses(element: HTMLElement): string[] { return classNameStr.split(' ').filter((c: string) => c.trim()); } -export function adaptTargetToElement(element: HTMLElement): TargetedElement { - return { +export function adaptTargetToElement( + element: HTMLElement, + options: ElementSerializationOptions = {}, +): TargetedElement { + const textDetail = options.textDetail ?? DEFAULT_TEXT_DETAIL; + const cssLevel = options.cssLevel ?? DEFAULT_CSS_LEVEL; + + const textVariants = collectTextVariants(element); + const resolvedText = resolveTextByDetail(textVariants, textDetail); + + const computedStyle = window.getComputedStyle(element); + const fullCSS = extractFullCSSProperties(computedStyle); + const cssProperties = getElementCSSProperties(computedStyle, cssLevel, fullCSS); + + const target: TargetedElement = { selector: generateSelector(element), tagName: element.tagName, id: element.id || undefined, classes: getElementClasses(element), - innerText: element.innerText || element.textContent || '', attributes: getElementAttributes(element), position: getElementPosition(element), - cssProperties: getElementCSSProperties(element), + cssLevel, + cssProperties, + cssComputed: Object.keys(fullCSS).length > 0 ? fullCSS : undefined, componentInfo: getReactFiberInfo(element), timestamp: Date.now(), url: window.location.href, + textDetail, + textVariants, + textContent: textVariants.full, }; + + if (resolvedText !== undefined) { + target.innerText = resolvedText; + } + + if (!target.textContent && textVariants.visible) { + target.textContent = textVariants.visible; + } + + return target; } /** diff --git a/packages/chrome-extension/src/utils/types.ts b/packages/chrome-extension/src/utils/types.ts index 29cca88..a001d58 100644 --- a/packages/chrome-extension/src/utils/types.ts +++ b/packages/chrome-extension/src/utils/types.ts @@ -5,10 +5,14 @@ export interface TargetedElement { tagName: string; id?: string; classes: string[]; - innerText: string; + innerText?: string; + textContent?: string; + textDetail?: 'full' | 'visible' | 'none'; attributes: Record; position: ElementPosition; - cssProperties: CSSProperties; + cssLevel?: 0 | 1 | 2 | 3; + cssProperties?: CSSProperties; + cssComputed?: Record; componentInfo?: ComponentInfo; timestamp: number; url: string; From 3254237c9c049cd7f0d5db4884c3a142306f41bf Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Sat, 27 Sep 2025 13:47:10 -0700 Subject: [PATCH 03/13] feat(server): allow textDetail/cssLevel request parameters and prune responses --- packages/server/src/services/mcp-service.ts | 33 +++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/server/src/services/mcp-service.ts b/packages/server/src/services/mcp-service.ts index 33885eb..42cfcd4 100644 --- a/packages/server/src/services/mcp-service.ts +++ b/packages/server/src/services/mcp-service.ts @@ -5,7 +5,14 @@ import { ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { version } from 'process'; +import { CSS_DETAIL_OPTIONS, TEXT_DETAIL_OPTIONS } from '@mcp-pointer/shared/detail'; import SharedStateService from './shared-state-service'; +import { + normalizeDetailParameters, + shapeElementForDetail, + type DetailParameters, + type NormalizedDetailParameters, +} from '../utils/element-detail'; enum MCPToolName { GET_POINTED_ELEMENT = 'get-pointed-element', @@ -47,10 +54,21 @@ export default class MCPService { tools: [ { name: MCPToolName.GET_POINTED_ELEMENT, - description: 'Get information about the currently pointed/shown DOM element from the browser extension, in order to let you see a specific element the user is showing you on his/her the browser.', + description: 'Get information about the currently pointed/shown DOM element. Control returned payload size with optional textDetail (full|visible|none) and cssLevel (0-3).', inputSchema: { type: 'object', - properties: {}, + properties: { + textDetail: { + type: 'string', + enum: [...TEXT_DETAIL_OPTIONS], + description: 'Controls how much text is returned. full (default) includes hidden text fallback, visible uses only rendered text, none omits text fields.', + }, + cssLevel: { + type: 'integer', + enum: [...CSS_DETAIL_OPTIONS], + description: 'Controls CSS payload detail. 0 omits CSS, 1 includes layout basics, 2 adds box model, 3 returns the full computed style.', + }, + }, required: [], }, }, @@ -60,13 +78,16 @@ export default class MCPService { private async handleCallTool(request: any) { if (request.params.name === MCPToolName.GET_POINTED_ELEMENT) { - return this.getPointedElement(); + const normalized = normalizeDetailParameters( + request.params.arguments as DetailParameters | undefined, + ); + return this.getPointedElement(normalized); } throw new Error(`Unknown tool: ${request.params.name}`); } - private async getPointedElement() { + private async getPointedElement(details: NormalizedDetailParameters) { const processedElement = await this.sharedState.getPointedElement(); if (!processedElement) { @@ -81,11 +102,13 @@ export default class MCPService { }; } + const shapedElement = shapeElementForDetail(processedElement, details.textDetail, details.cssLevel); + return { content: [ { type: 'text', - text: JSON.stringify(processedElement, null, 2), + text: JSON.stringify(shapedElement, null, 2), }, ], }; From 041a2ba39755a13530ebf8e653974f8ff2387bf2 Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Sat, 27 Sep 2025 13:47:44 -0700 Subject: [PATCH 04/13] feat(server): add element shaping utility for dynamic detail control --- packages/server/src/__tests__/test-helpers.ts | 17 +++++++++- packages/server/src/mcp-handler.ts | 33 ++++++++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/server/src/__tests__/test-helpers.ts b/packages/server/src/__tests__/test-helpers.ts index 7778fcb..fde88ee 100644 --- a/packages/server/src/__tests__/test-helpers.ts +++ b/packages/server/src/__tests__/test-helpers.ts @@ -25,16 +25,24 @@ export async function cleanupTestFiles(): Promise { } export function createMockElement(): TargetedElement { + const text = 'Test Element'; return { selector: 'div.test-element', tagName: 'DIV', id: 'test-id', classes: ['test-class'], - innerText: 'Test Element', + innerText: text, + textContent: text, + textDetail: 'full', + textVariants: { + visible: text, + full: text, + }, attributes: { 'data-test': 'true' }, position: { x: 100, y: 200, width: 300, height: 50, }, + cssLevel: 1, cssProperties: { display: 'block', position: 'relative', @@ -42,6 +50,13 @@ export function createMockElement(): TargetedElement { color: 'rgb(0, 0, 0)', backgroundColor: 'rgb(255, 255, 255)', }, + cssComputed: { + display: 'block', + position: 'relative', + fontSize: '16px', + color: 'rgb(0, 0, 0)', + backgroundColor: 'rgb(255, 255, 255)', + }, timestamp: Date.now(), url: 'https://example.com', tabId: 123, diff --git a/packages/server/src/mcp-handler.ts b/packages/server/src/mcp-handler.ts index 60a82cf..a47e4e0 100644 --- a/packages/server/src/mcp-handler.ts +++ b/packages/server/src/mcp-handler.ts @@ -5,7 +5,14 @@ import { ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { version } from 'process'; +import { CSS_DETAIL_OPTIONS, TEXT_DETAIL_OPTIONS } from '@mcp-pointer/shared/detail'; import type PointerWebSocketServer from './websocket-server'; +import { + normalizeDetailParameters, + shapeElementForDetail, + type DetailParameters, + type NormalizedDetailParameters, +} from './utils/element-detail'; enum MCPToolName { GET_POINTED_ELEMENT = 'get-pointed-element', @@ -47,10 +54,21 @@ export default class MCPHandler { tools: [ { name: MCPToolName.GET_POINTED_ELEMENT, - description: 'Get information about the currently pointed/shown DOM element from the browser extension, in order to let you see a specific element the user is showing you on his/her the browser.', + description: 'Get information about the currently pointed/shown DOM element. Control returned payload size with optional textDetail (full|visible|none) and cssLevel (0-3).', inputSchema: { type: 'object', - properties: {}, + properties: { + textDetail: { + type: 'string', + enum: [...TEXT_DETAIL_OPTIONS], + description: 'Controls how much text is returned. full (default) includes hidden text fallback, visible uses only rendered text, none omits text fields.', + }, + cssLevel: { + type: 'integer', + enum: [...CSS_DETAIL_OPTIONS], + description: 'Controls CSS payload detail. 0 omits CSS, 1 includes layout basics, 2 adds box model, 3 returns the full computed style.', + }, + }, required: [], }, }, @@ -60,13 +78,16 @@ export default class MCPHandler { private async handleCallTool(request: any) { if (request.params.name === MCPToolName.GET_POINTED_ELEMENT) { - return this.getTargetedElement(); + const normalized = normalizeDetailParameters( + request.params.arguments as DetailParameters | undefined, + ); + return this.getTargetedElement(normalized); } throw new Error(`Unknown tool: ${request.params.name}`); } - private getTargetedElement() { + private getTargetedElement(details: NormalizedDetailParameters) { const element = this.wsServer.getCurrentElement(); if (!element) { @@ -81,11 +102,13 @@ export default class MCPHandler { }; } + const shapedElement = shapeElementForDetail(element, details.textDetail, details.cssLevel); + return { content: [ { type: 'text', - text: JSON.stringify(element, null, 2), + text: JSON.stringify(shapedElement, null, 2), }, ], }; From de8f24984b181cc9cb92a89b614af29bf3f0cc59 Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Sat, 27 Sep 2025 13:47:58 -0700 Subject: [PATCH 05/13] feat(server): cover element detail normalization and shaping --- .../__tests__/utils/element-detail.test.ts | 80 +++++++ packages/server/src/utils/element-detail.ts | 196 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 packages/server/src/__tests__/utils/element-detail.test.ts create mode 100644 packages/server/src/utils/element-detail.ts diff --git a/packages/server/src/__tests__/utils/element-detail.test.ts b/packages/server/src/__tests__/utils/element-detail.test.ts new file mode 100644 index 0000000..ef5dfcc --- /dev/null +++ b/packages/server/src/__tests__/utils/element-detail.test.ts @@ -0,0 +1,80 @@ +import { + normalizeDetailParameters, + normalizeCssLevel, + normalizeTextDetail, + shapeElementForDetail, +} from '../../utils/element-detail'; +import { createMockElement } from '../test-helpers'; + +describe('element-detail utilities', () => { + describe('normalizeTextDetail', () => { + it('returns defaults for invalid values', () => { + expect(normalizeTextDetail(undefined)).toBe('full'); + expect(normalizeTextDetail('VISIBLE')).toBe('visible'); + expect(normalizeTextDetail('invalid', 'visible')).toBe('visible'); + }); + }); + + describe('normalizeCssLevel', () => { + it('coerces numeric strings and falls back to default', () => { + expect(normalizeCssLevel('2')).toBe(2); + expect(normalizeCssLevel('not-a-number', 3)).toBe(3); + expect(normalizeCssLevel(undefined)).toBe(1); + }); + }); + + describe('normalizeDetailParameters', () => { + it('applies defaults when params are missing', () => { + expect(normalizeDetailParameters(undefined)).toEqual({ + textDetail: 'full', + cssLevel: 1, + }); + }); + + it('normalizes provided params', () => { + expect(normalizeDetailParameters({ textDetail: 'visible', cssLevel: '0' })).toEqual({ + textDetail: 'visible', + cssLevel: 0, + }); + }); + }); + + describe('shapeElementForDetail', () => { + it('omits text and css when levels request none', () => { + const element = createMockElement(); + const shaped = shapeElementForDetail(element, 'none', 0); + + expect(shaped.innerText).toBeUndefined(); + expect(shaped.textContent).toBeUndefined(); + expect(shaped.cssProperties).toBeUndefined(); + expect(shaped.cssLevel).toBe(0); + }); + + it('returns visible text and level 1 css subset', () => { + const element = createMockElement(); + element.textVariants!.visible = 'Visible text only'; + element.textVariants!.full = 'Visible text only with hidden'; + const shaped = shapeElementForDetail(element, 'visible', 1); + + expect(shaped.innerText).toBe('Visible text only'); + expect(shaped.textContent).toBeUndefined(); + expect(shaped.cssProperties).toBeDefined(); + expect(Object.keys(shaped.cssProperties!)).toContain('display'); + expect(Object.keys(shaped.cssProperties!)).not.toContain('marginTop'); + }); + + it('returns full css when level 3 requested', () => { + const element = createMockElement(); + element.cssComputed = { + ...element.cssProperties!, + marginTop: '5px', + }; + + const shaped = shapeElementForDetail(element, 'full', 3); + expect(shaped.cssProperties).toEqual({ + ...element.cssComputed, + }); + expect(shaped.textContent).toBe(element.textVariants!.full); + }); + }); +}); diff --git a/packages/server/src/utils/element-detail.ts b/packages/server/src/utils/element-detail.ts new file mode 100644 index 0000000..75a70a9 --- /dev/null +++ b/packages/server/src/utils/element-detail.ts @@ -0,0 +1,196 @@ +import { + CSSDetailLevel, + CSSProperties, + DEFAULT_CSS_LEVEL, + DEFAULT_TEXT_DETAIL, + TargetedElement, + TextDetailLevel, + TextSnapshots, +} from '@mcp-pointer/shared/types'; +import { + CSS_LEVEL_FIELD_MAP, + isValidCSSLevel, + isValidTextDetail, +} from '@mcp-pointer/shared/detail'; + +export interface DetailParameters { + textDetail?: unknown; + cssLevel?: unknown; +} + +export interface NormalizedDetailParameters { + textDetail: TextDetailLevel; + cssLevel: CSSDetailLevel; +} + +function toNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + + return null; +} + +export function normalizeTextDetail( + detail: unknown, + fallback: TextDetailLevel = DEFAULT_TEXT_DETAIL, +): TextDetailLevel { + if (isValidTextDetail(detail)) { + return detail; + } + + if (typeof detail === 'string') { + const lowered = detail.toLowerCase(); + if (isValidTextDetail(lowered)) { + return lowered as TextDetailLevel; + } + } + + return fallback; +} + +export function normalizeCssLevel( + level: unknown, + fallback: CSSDetailLevel = DEFAULT_CSS_LEVEL, +): CSSDetailLevel { + if (isValidCSSLevel(level)) { + return level; + } + + const parsed = toNumber(level); + if (parsed !== null && isValidCSSLevel(parsed)) { + return parsed; + } + + return fallback; +} + +export function normalizeDetailParameters( + params: DetailParameters | undefined, + defaults?: Partial, +): NormalizedDetailParameters { + return { + textDetail: normalizeTextDetail( + params?.textDetail, + defaults?.textDetail ?? DEFAULT_TEXT_DETAIL, + ), + cssLevel: normalizeCssLevel( + params?.cssLevel, + defaults?.cssLevel ?? DEFAULT_CSS_LEVEL, + ), + }; +} + +function resolveTextVariants(element: TargetedElement): TextSnapshots { + const visible = element.textVariants?.visible ?? element.innerText ?? ''; + const full = element.textVariants?.full ?? element.textContent ?? visible; + + return { + visible, + full, + }; +} + +function resolveTextContent( + variants: TextSnapshots, + detail: TextDetailLevel, +): string | undefined { + if (detail === 'none') { + return undefined; + } + + if (detail === 'visible') { + return variants.visible; + } + + return variants.full || variants.visible; +} + +function buildCssProperties( + element: TargetedElement, + cssLevel: CSSDetailLevel, +): CSSProperties | undefined { + if (cssLevel === 0) { + return undefined; + } + + if (cssLevel === 3) { + if (element.cssComputed) { + return { ...element.cssComputed }; + } + + if (element.cssProperties) { + return { ...element.cssProperties }; + } + + return undefined; + } + + const fields = CSS_LEVEL_FIELD_MAP[cssLevel]; + const cssProperties: CSSProperties = {}; + const source = element.cssComputed ?? element.cssProperties ?? {}; + + fields.forEach((property) => { + const value = source[property]; + if (value !== undefined) { + cssProperties[property] = value; + } + }); + + if (Object.keys(cssProperties).length > 0) { + return cssProperties; + } + + if (element.cssProperties) { + return { ...element.cssProperties }; + } + + return undefined; +} + +export function shapeElementForDetail( + element: TargetedElement, + detail: TextDetailLevel, + cssLevel: CSSDetailLevel, +): TargetedElement { + const variants = resolveTextVariants(element); + const resolvedText = resolveTextContent(variants, detail); + const textContent = detail === 'full' ? variants.full : undefined; + const cssProperties = buildCssProperties(element, cssLevel); + + const shaped: TargetedElement = { + selector: element.selector, + tagName: element.tagName, + id: element.id, + classes: [...element.classes], + attributes: { ...element.attributes }, + position: { ...element.position }, + cssLevel, + componentInfo: element.componentInfo ? { ...element.componentInfo } : undefined, + timestamp: element.timestamp, + url: element.url, + tabId: element.tabId, + textDetail: detail, + }; + + if (resolvedText !== undefined) { + shaped.innerText = resolvedText; + } + + if (textContent !== undefined) { + shaped.textContent = textContent; + } + + if (cssProperties) { + shaped.cssProperties = cssProperties; + } + + return shaped; +} From cf3fc32ef648667b291ea3111759eb37837af03e Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Sat, 27 Sep 2025 13:49:02 -0700 Subject: [PATCH 06/13] docs(readme): document optional MCP tool arguments for context control --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a2c5ac..042d3a8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ The extension lets you visually select DOM elements in the browser, and the MCP - 🎯 **`Option+Click` Selection** - Simply hold `Option` (Alt on Windows) and click any element - πŸ“‹ **Complete Element Data** - Text content, CSS classes, HTML attributes, positioning, and styling +- πŸ’‘ **Dynamic Context Control** - Request visible-only text, suppress text entirely, or dial CSS detail from none β†’ full computed styles per MCP call - βš›οΈ **React Component Detection** - Component names and source files via Fiber (experimental) - πŸ”— **WebSocket Connection** - Real-time communication between browser and AI tools - πŸ€– **MCP Compatible** - Works with Claude Code and other MCP-enabled AI tools @@ -102,7 +103,9 @@ After configuration, **restart your coding tool** to load the MCP connection. Your AI tool will automatically start the MCP server when needed using the `npx -y @mcp-pointer/server@latest start` command. **Available MCP Tool:** -- `get-pointed-element` - Get textual information about the currently pointed DOM element from the browser extension +- `get-pointed-element` – Returns textual information about the currently pointed DOM element. Optional arguments: + - `textDetail`: `"full" | "visible" | "none"` (default `"full"`) controls how much text to include. + - `cssLevel`: `0 | 1 | 2 | 3` (default `1`) controls styling detail, from no CSS (0) up to full computed styles (3). ## 🎯 How It Works From 2e468ab6e1678caea0e1699fb5b60dd8eedceef3 Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Sat, 27 Sep 2025 13:57:11 -0700 Subject: [PATCH 07/13] feat(changeset): update version with minor change patch --- packages/chrome-extension/CHANGELOG.md | 4 ++++ packages/server/CHANGELOG.md | 2 ++ packages/shared/CHANGELOG.md | 2 ++ 3 files changed, 8 insertions(+) diff --git a/packages/chrome-extension/CHANGELOG.md b/packages/chrome-extension/CHANGELOG.md index c9d4186..6980fb6 100644 --- a/packages/chrome-extension/CHANGELOG.md +++ b/packages/chrome-extension/CHANGELOG.md @@ -20,6 +20,10 @@ ## 0.5.0 +### Minor Changes + +- Added dynamic context control (text detail & css levels) + ### Patch Changes - Updated dependencies [d91e764] diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index cc7913e..4a4aa6c 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -20,6 +20,8 @@ Server ready for browser extension updates. +- Added dynamic context control (text detail & css levels) + ### Patch Changes - 1c9cef4: Replace jsdom with node-html-parser for better bundling diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md index 0c2e363..c343aac 100644 --- a/packages/shared/CHANGELOG.md +++ b/packages/shared/CHANGELOG.md @@ -14,6 +14,8 @@ Server ready for browser extension updates. +- Added dynamic context control (text detail & css levels) + ## 0.3.1 ### Patch Changes From 7cf06781ff1d1344ddd78f72d5add8ca5afde054 Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Tue, 30 Sep 2025 18:54:39 -0700 Subject: [PATCH 08/13] fix(extension): use LEGACY_ELEMENT_SELECTED enum value - Extension was referencing PointerMessageType.ELEMENT_SELECTED which doesn't exist - Changed to PointerMessageType.LEGACY_ELEMENT_SELECTED in element-sender-service.ts - Fixed websocket-server.ts to remove invalid ELEMENT_CLEARED reference (dead code) - Fixed lint error in mcp-service.ts (line length) - Fixes bug introduced in PR #14 --- packages/server/src/services/mcp-service.ts | 6 +++++- packages/server/src/websocket-server.ts | 4 +--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/server/src/services/mcp-service.ts b/packages/server/src/services/mcp-service.ts index 42cfcd4..7e4fa12 100644 --- a/packages/server/src/services/mcp-service.ts +++ b/packages/server/src/services/mcp-service.ts @@ -102,7 +102,11 @@ export default class MCPService { }; } - const shapedElement = shapeElementForDetail(processedElement, details.textDetail, details.cssLevel); + const shapedElement = shapeElementForDetail( + processedElement, + details.textDetail, + details.cssLevel, + ); return { content: [ diff --git a/packages/server/src/websocket-server.ts b/packages/server/src/websocket-server.ts index 8ae0b36..5811869 100644 --- a/packages/server/src/websocket-server.ts +++ b/packages/server/src/websocket-server.ts @@ -46,10 +46,8 @@ export default class PointerWebSocketServer { } private handleMessage(message: PointerMessage): void { - if (message.type === PointerMessageType.ELEMENT_SELECTED && message.data) { + if (message.type === PointerMessageType.LEGACY_ELEMENT_SELECTED && message.data) { this.currentElement = message.data as TargetedElement; - } else if (message.type === PointerMessageType.ELEMENT_CLEARED) { - this.currentElement = null; } } From b5b1d296605e0d85b9a2a2d3f56660d9bf4e316a Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Tue, 30 Sep 2025 18:55:47 -0700 Subject: [PATCH 09/13] feat(server): adapt dynamic context control to ProcessedPointedDOMElement - Add cssComputed and textContent fields to ProcessedPointedDOMElement type - Update ElementProcessor to capture full computed styles from raw data - Adapt element-detail.ts to shape ProcessedPointedDOMElement instead of TargetedElement - Update tests to use ProcessedPointedDOMElement - All shaping logic now works server-side on processed elements --- .../__tests__/utils/element-detail.test.ts | 60 +++++++++++++------ .../server/src/services/element-processor.ts | 2 + packages/server/src/types.ts | 6 +- packages/server/src/utils/element-detail.ts | 42 ++++--------- 4 files changed, 63 insertions(+), 47 deletions(-) diff --git a/packages/server/src/__tests__/utils/element-detail.test.ts b/packages/server/src/__tests__/utils/element-detail.test.ts index ef5dfcc..ec050af 100644 --- a/packages/server/src/__tests__/utils/element-detail.test.ts +++ b/packages/server/src/__tests__/utils/element-detail.test.ts @@ -4,7 +4,40 @@ import { normalizeTextDetail, shapeElementForDetail, } from '../../utils/element-detail'; -import { createMockElement } from '../test-helpers'; +import { ProcessedPointedDOMElement } from '../../types'; + +function createMockProcessedElement(): ProcessedPointedDOMElement { + return { + selector: 'div.test-element', + tagName: 'DIV', + id: 'test-id', + classes: ['test-class'], + innerText: 'Visible text', + textContent: 'Visible text with hidden content', + attributes: { 'data-test': 'true' }, + position: { + x: 100, y: 200, width: 300, height: 50, + }, + cssProperties: { + display: 'block', + position: 'relative', + fontSize: '16px', + color: 'rgb(0, 0, 0)', + backgroundColor: 'rgb(255, 255, 255)', + }, + cssComputed: { + display: 'block', + position: 'relative', + fontSize: '16px', + color: 'rgb(0, 0, 0)', + backgroundColor: 'rgb(255, 255, 255)', + marginTop: '10px', + paddingLeft: '5px', + }, + timestamp: new Date().toISOString(), + url: 'https://example.com', + }; +} describe('element-detail utilities', () => { describe('normalizeTextDetail', () => { @@ -41,19 +74,18 @@ describe('element-detail utilities', () => { describe('shapeElementForDetail', () => { it('omits text and css when levels request none', () => { - const element = createMockElement(); + const element = createMockProcessedElement(); const shaped = shapeElementForDetail(element, 'none', 0); - expect(shaped.innerText).toBeUndefined(); + expect(shaped.innerText).toBe(''); expect(shaped.textContent).toBeUndefined(); expect(shaped.cssProperties).toBeUndefined(); - expect(shaped.cssLevel).toBe(0); }); it('returns visible text and level 1 css subset', () => { - const element = createMockElement(); - element.textVariants!.visible = 'Visible text only'; - element.textVariants!.full = 'Visible text only with hidden'; + const element = createMockProcessedElement(); + element.innerText = 'Visible text only'; + element.textContent = 'Visible text only with hidden'; const shaped = shapeElementForDetail(element, 'visible', 1); expect(shaped.innerText).toBe('Visible text only'); @@ -64,17 +96,11 @@ describe('element-detail utilities', () => { }); it('returns full css when level 3 requested', () => { - const element = createMockElement(); - element.cssComputed = { - ...element.cssProperties!, - marginTop: '5px', - }; - + const element = createMockProcessedElement(); const shaped = shapeElementForDetail(element, 'full', 3); - expect(shaped.cssProperties).toEqual({ - ...element.cssComputed, - }); - expect(shaped.textContent).toBe(element.textVariants!.full); + + expect(shaped.cssProperties).toEqual(element.cssComputed); + expect(shaped.textContent).toBe(element.textContent); }); }); }); diff --git a/packages/server/src/services/element-processor.ts b/packages/server/src/services/element-processor.ts index 4c446e4..0700639 100644 --- a/packages/server/src/services/element-processor.ts +++ b/packages/server/src/services/element-processor.ts @@ -26,6 +26,7 @@ export default class ElementProcessor { classes: element ? Array.from(element.classList) : [], attributes: element ? this.getAttributes(element) : {}, innerText: element?.textContent || '', + textContent: element?.textContent || undefined, selector: element ? generateSelector(element) : 'unknown', position: this.getPosition(raw.boundingClientRect), @@ -33,6 +34,7 @@ export default class ElementProcessor { timestamp: new Date(raw.timestamp).toISOString(), cssProperties: this.getRelevantStyles(raw.computedStyles), + cssComputed: raw.computedStyles ? { ...raw.computedStyles } : undefined, componentInfo: this.getComponentInfo(raw.reactFiber), warnings: allWarnings.length > 0 ? allWarnings : undefined, diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 4106191..7547f3a 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -21,10 +21,14 @@ export interface ProcessedPointedDOMElement { url: string; timestamp: string; // ISO format - // Optional processing + // Full CSS data for shaping cssProperties?: CSSProperties; + cssComputed?: Record; // Full computed styles componentInfo?: ComponentInfo; + // Text content (full, including hidden nodes) + textContent?: string; + // Processing metadata warnings?: string[]; } diff --git a/packages/server/src/utils/element-detail.ts b/packages/server/src/utils/element-detail.ts index 75a70a9..3b40480 100644 --- a/packages/server/src/utils/element-detail.ts +++ b/packages/server/src/utils/element-detail.ts @@ -3,15 +3,14 @@ import { CSSProperties, DEFAULT_CSS_LEVEL, DEFAULT_TEXT_DETAIL, - TargetedElement, TextDetailLevel, - TextSnapshots, } from '@mcp-pointer/shared/types'; import { CSS_LEVEL_FIELD_MAP, isValidCSSLevel, isValidTextDetail, } from '@mcp-pointer/shared/detail'; +import { ProcessedPointedDOMElement } from '../types'; export interface DetailParameters { textDetail?: unknown; @@ -88,18 +87,8 @@ export function normalizeDetailParameters( }; } -function resolveTextVariants(element: TargetedElement): TextSnapshots { - const visible = element.textVariants?.visible ?? element.innerText ?? ''; - const full = element.textVariants?.full ?? element.textContent ?? visible; - - return { - visible, - full, - }; -} - function resolveTextContent( - variants: TextSnapshots, + element: ProcessedPointedDOMElement, detail: TextDetailLevel, ): string | undefined { if (detail === 'none') { @@ -107,14 +96,15 @@ function resolveTextContent( } if (detail === 'visible') { - return variants.visible; + return element.innerText; } - return variants.full || variants.visible; + // 'full' - return textContent if available, otherwise innerText + return element.textContent ?? element.innerText; } function buildCssProperties( - element: TargetedElement, + element: ProcessedPointedDOMElement, cssLevel: CSSDetailLevel, ): CSSProperties | undefined { if (cssLevel === 0) { @@ -156,34 +146,28 @@ function buildCssProperties( } export function shapeElementForDetail( - element: TargetedElement, + element: ProcessedPointedDOMElement, detail: TextDetailLevel, cssLevel: CSSDetailLevel, -): TargetedElement { - const variants = resolveTextVariants(element); - const resolvedText = resolveTextContent(variants, detail); - const textContent = detail === 'full' ? variants.full : undefined; +): ProcessedPointedDOMElement { + const resolvedText = resolveTextContent(element, detail); + const textContent = detail === 'full' ? element.textContent : undefined; const cssProperties = buildCssProperties(element, cssLevel); - const shaped: TargetedElement = { + const shaped: ProcessedPointedDOMElement = { selector: element.selector, tagName: element.tagName, id: element.id, classes: [...element.classes], attributes: { ...element.attributes }, position: { ...element.position }, - cssLevel, componentInfo: element.componentInfo ? { ...element.componentInfo } : undefined, timestamp: element.timestamp, url: element.url, - tabId: element.tabId, - textDetail: detail, + innerText: resolvedText ?? '', + warnings: element.warnings, }; - if (resolvedText !== undefined) { - shaped.innerText = resolvedText; - } - if (textContent !== undefined) { shaped.textContent = textContent; } From cbb470400988d8b4f3999731fd6d5021a937e9fd Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Wed, 1 Oct 2025 14:34:02 -0700 Subject: [PATCH 10/13] feat(server): store full CSS properties in ProcessedPointedDOMElement - Modified getRelevantStyles() to return all computed styles instead of filtering to 5 properties - Updated test mocks to reflect full CSS data in cssProperties - Enables full CSS details without re-pointing, with server-side filtering on MCP tool calls --- .../server/src/__tests__/utils/element-detail.test.ts | 2 ++ packages/server/src/services/element-processor.ts | 8 +------- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/server/src/__tests__/utils/element-detail.test.ts b/packages/server/src/__tests__/utils/element-detail.test.ts index ec050af..a858bd0 100644 --- a/packages/server/src/__tests__/utils/element-detail.test.ts +++ b/packages/server/src/__tests__/utils/element-detail.test.ts @@ -24,6 +24,8 @@ function createMockProcessedElement(): ProcessedPointedDOMElement { fontSize: '16px', color: 'rgb(0, 0, 0)', backgroundColor: 'rgb(255, 255, 255)', + marginTop: '10px', + paddingLeft: '5px', }, cssComputed: { display: 'block', diff --git a/packages/server/src/services/element-processor.ts b/packages/server/src/services/element-processor.ts index 0700639..263968e 100644 --- a/packages/server/src/services/element-processor.ts +++ b/packages/server/src/services/element-processor.ts @@ -67,13 +67,7 @@ export default class ElementProcessor { private getRelevantStyles(styles?: Record): CSSProperties | undefined { if (!styles) return undefined; - return { - display: safeGet(styles, 'display', 'block'), - position: safeGet(styles, 'position', 'static'), - fontSize: safeGet(styles, 'font-size', '16px'), - color: safeGet(styles, 'color', 'black'), - backgroundColor: safeGet(styles, 'background-color', 'transparent'), - }; + return { ...styles }; } private getComponentInfo(reactFiber?: any): ComponentInfo | undefined { From 4ce493e5a32e510003df9b29e8f8d4bc9f914a26 Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Wed, 1 Oct 2025 14:35:36 -0700 Subject: [PATCH 11/13] refactor(server): remove LEGACY_ELEMENT_SELECTED support and dead code - Remove LEGACY_ELEMENT_SELECTED message handling - only DOM_ELEMENT_POINTED supported - Delete unused mcp-handler.ts and websocket-server.ts files - Remove StateDataV1 and LegacySharedState types - Simplify SharedStateService to only handle V2 format - Update tests to remove legacy test cases --- .../factories/shared-state-factory.ts | 42 +----- .../services/shared-state-service.test.ts | 24 +--- packages/server/src/mcp-handler.ts | 121 ------------------ packages/server/src/message-handler.ts | 41 ++---- .../src/services/shared-state-service.ts | 21 +-- packages/server/src/types.ts | 21 +-- packages/server/src/websocket-server.ts | 64 --------- 7 files changed, 23 insertions(+), 311 deletions(-) delete mode 100644 packages/server/src/mcp-handler.ts delete mode 100644 packages/server/src/websocket-server.ts diff --git a/packages/server/src/__tests__/factories/shared-state-factory.ts b/packages/server/src/__tests__/factories/shared-state-factory.ts index 03bdb79..76a4d91 100644 --- a/packages/server/src/__tests__/factories/shared-state-factory.ts +++ b/packages/server/src/__tests__/factories/shared-state-factory.ts @@ -1,6 +1,6 @@ -import { TargetedElement, RawPointedDOMElement, PointerMessageType } from '@mcp-pointer/shared/types'; +import { RawPointedDOMElement, PointerMessageType } from '@mcp-pointer/shared/types'; import { - SharedState, StateDataV1, StateDataV2, ProcessedPointedDOMElement, + SharedState, StateDataV2, ProcessedPointedDOMElement, } from '../../types'; export const createProcessedElement = ( @@ -39,29 +39,6 @@ export const createRawElement = ( ...overrides, }); -export const createLegacyElement = ( - overrides: Partial = {}, -): TargetedElement => ({ - selector: 'div', - tagName: 'div', - classes: [], - innerText: 'test content', - attributes: {}, - position: { - x: 10, y: 20, width: 100, height: 50, - }, - cssProperties: { - display: 'block', - position: 'relative', - fontSize: '16px', - color: '#000000', - backgroundColor: '#ffffff', - }, - timestamp: 1672531200000, - url: 'https://example.com', - ...overrides, -}); - export const createStateV2 = ( rawOverrides: Partial = {}, processedOverrides: Partial = {}, @@ -76,18 +53,3 @@ export const createStateV2 = ( }, } as StateDataV2, }); - -export const createStateV1 = ( - legacyOverrides: Partial = {}, - processedOverrides: Partial = {}, -): SharedState => ({ - stateVersion: 1, - data: { - rawPointedDOMElement: createLegacyElement(legacyOverrides), - processedPointedDOMElement: createProcessedElement(processedOverrides), - metadata: { - receivedAt: '2023-01-01T00:00:00.000Z', - messageType: PointerMessageType.LEGACY_ELEMENT_SELECTED, - }, - } as StateDataV1, -}); diff --git a/packages/server/src/__tests__/services/shared-state-service.test.ts b/packages/server/src/__tests__/services/shared-state-service.test.ts index 12a1c0b..95efc4e 100644 --- a/packages/server/src/__tests__/services/shared-state-service.test.ts +++ b/packages/server/src/__tests__/services/shared-state-service.test.ts @@ -2,7 +2,7 @@ import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import SharedStateService from '../../services/shared-state-service'; -import { createStateV1, createStateV2, createLegacyElement } from '../factories/shared-state-factory'; +import { createStateV2 } from '../factories/shared-state-factory'; jest.mock('../../logger', () => ({ debug: jest.fn(), @@ -41,7 +41,7 @@ describe('SharedStateService', () => { it('overwrites corrupted file', async () => { await fs.writeFile(testPath, 'invalid json'); - const state = createStateV1(); + const state = createStateV2(); await service.saveState(state); @@ -53,7 +53,7 @@ describe('SharedStateService', () => { }); describe('getPointedElement', () => { - it('returns processed element from v2 state', async () => { + it('returns processed element from state', async () => { const state = createStateV2(); await fs.writeFile(testPath, JSON.stringify(state)); @@ -62,24 +62,6 @@ describe('SharedStateService', () => { expect(result).toEqual(state.data.processedPointedDOMElement); }); - it('returns processed element from v1 state', async () => { - const state = createStateV1(); - await fs.writeFile(testPath, JSON.stringify(state)); - - const result = await service.getPointedElement(); - - expect(result).toEqual(state.data.processedPointedDOMElement); - }); - - it('returns legacy element as-is', async () => { - const legacyElement = createLegacyElement(); - await fs.writeFile(testPath, JSON.stringify(legacyElement)); - - const result = await service.getPointedElement(); - - expect(result).toEqual(legacyElement); - }); - it('returns null for invalid json', async () => { await fs.writeFile(testPath, 'invalid json'); diff --git a/packages/server/src/mcp-handler.ts b/packages/server/src/mcp-handler.ts deleted file mode 100644 index a47e4e0..0000000 --- a/packages/server/src/mcp-handler.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from '@modelcontextprotocol/sdk/types.js'; -import { version } from 'process'; -import { CSS_DETAIL_OPTIONS, TEXT_DETAIL_OPTIONS } from '@mcp-pointer/shared/detail'; -import type PointerWebSocketServer from './websocket-server'; -import { - normalizeDetailParameters, - shapeElementForDetail, - type DetailParameters, - type NormalizedDetailParameters, -} from './utils/element-detail'; - -enum MCPToolName { - GET_POINTED_ELEMENT = 'get-pointed-element', -} - -enum MCPServerName { - MCP_POINTER_SERVER = '@mcp-pointer/server', -} - -export default class MCPHandler { - private server: Server; - - private wsServer: PointerWebSocketServer; - - constructor(wsServer: PointerWebSocketServer) { - this.wsServer = wsServer; - this.server = new Server( - { - name: MCPServerName.MCP_POINTER_SERVER, - version, - }, - { - capabilities: { - tools: {}, - }, - }, - ); - - this.setupHandlers(); - } - - private setupHandlers(): void { - this.server.setRequestHandler(ListToolsRequestSchema, this.handleListTools.bind(this)); - this.server.setRequestHandler(CallToolRequestSchema, this.handleCallTool.bind(this)); - } - - private async handleListTools() { - return { - tools: [ - { - name: MCPToolName.GET_POINTED_ELEMENT, - description: 'Get information about the currently pointed/shown DOM element. Control returned payload size with optional textDetail (full|visible|none) and cssLevel (0-3).', - inputSchema: { - type: 'object', - properties: { - textDetail: { - type: 'string', - enum: [...TEXT_DETAIL_OPTIONS], - description: 'Controls how much text is returned. full (default) includes hidden text fallback, visible uses only rendered text, none omits text fields.', - }, - cssLevel: { - type: 'integer', - enum: [...CSS_DETAIL_OPTIONS], - description: 'Controls CSS payload detail. 0 omits CSS, 1 includes layout basics, 2 adds box model, 3 returns the full computed style.', - }, - }, - required: [], - }, - }, - ], - }; - } - - private async handleCallTool(request: any) { - if (request.params.name === MCPToolName.GET_POINTED_ELEMENT) { - const normalized = normalizeDetailParameters( - request.params.arguments as DetailParameters | undefined, - ); - return this.getTargetedElement(normalized); - } - - throw new Error(`Unknown tool: ${request.params.name}`); - } - - private getTargetedElement(details: NormalizedDetailParameters) { - const element = this.wsServer.getCurrentElement(); - - if (!element) { - return { - content: [ - { - type: 'text', - text: 'No element is currently pointed. ' - + 'The user needs to point an element in their browser using Option+Click.', - }, - ], - }; - } - - const shapedElement = shapeElementForDetail(element, details.textDetail, details.cssLevel); - - return { - content: [ - { - type: 'text', - text: JSON.stringify(shapedElement, null, 2), - }, - ], - }; - } - - public async start(): Promise { - const transport = new StdioServerTransport(); - await this.server.connect(transport); - } -} diff --git a/packages/server/src/message-handler.ts b/packages/server/src/message-handler.ts index f2a6773..b531414 100644 --- a/packages/server/src/message-handler.ts +++ b/packages/server/src/message-handler.ts @@ -1,8 +1,8 @@ -import { PointerMessageType, type TargetedElement, type RawPointedDOMElement } from '@mcp-pointer/shared/types'; +import { PointerMessageType, type RawPointedDOMElement } from '@mcp-pointer/shared/types'; import logger from './logger'; import ElementProcessor from './services/element-processor'; import SharedStateService from './services/shared-state-service'; -import { SharedState, StateDataV1, StateDataV2 } from './types'; +import { SharedState, StateDataV2 } from './types'; function buildMetadata(messageType: string) { const now = new Date().toISOString(); @@ -13,32 +13,12 @@ function buildMetadata(messageType: string) { }; } -function buildLegacyState(type: string, data: any): SharedState { - logger.info('Processing legacy element format'); - const element = data as TargetedElement; - - const stateData: StateDataV1 = { - rawPointedDOMElement: element, - processedPointedDOMElement: { - ...element, - timestamp: new Date(element.timestamp).toISOString(), - warnings: undefined, - }, - metadata: buildMetadata(type), - }; - - return { - stateVersion: 1, - data: stateData, - }; -} - -function buildNewState( +function buildState( type: string, data: any, elementProcessor: ElementProcessor, ): SharedState { - logger.info('Processing new raw element format'); + logger.info('Processing raw element format'); const raw = data as RawPointedDOMElement; const processed = elementProcessor.processFromRaw(raw); @@ -59,15 +39,12 @@ function buildStateFromMessage( data: any, services: HandlerServices, ): SharedState | null { - switch (type) { - case PointerMessageType.LEGACY_ELEMENT_SELECTED: - return buildLegacyState(type, data); - case PointerMessageType.DOM_ELEMENT_POINTED: - return buildNewState(type, data, services.elementProcessor); - default: - logger.warn(`Received unknown message type: ${type}`); - return null; + if (type === PointerMessageType.DOM_ELEMENT_POINTED) { + return buildState(type, data, services.elementProcessor); } + + logger.warn(`Received unknown message type: ${type}`); + return null; } interface HandlerServices { diff --git a/packages/server/src/services/shared-state-service.ts b/packages/server/src/services/shared-state-service.ts index 40f89a1..e71f567 100644 --- a/packages/server/src/services/shared-state-service.ts +++ b/packages/server/src/services/shared-state-service.ts @@ -1,12 +1,10 @@ import fs from 'fs/promises'; -import { type TargetedElement } from '@mcp-pointer/shared/types'; -import { SharedState, ProcessedPointedDOMElement, LegacySharedState } from '../types'; +import { SharedState, ProcessedPointedDOMElement } from '../types'; import logger from '../logger'; export default class SharedStateService { static SHARED_STATE_PATH = '/tmp/mcp-pointer-shared-state.json'; - // New method for storing versioned data public async saveState(state: SharedState): Promise { try { const json = JSON.stringify(state, null, 2); @@ -18,23 +16,14 @@ export default class SharedStateService { } } - // Get processed element for MCP service - public async getPointedElement(): Promise { + public async getPointedElement(): Promise { const state = await this.readState(); - if (!state || typeof state !== 'object') return null; + if (!state) return null; - // If it's the new format, return the processed element - if ('stateVersion' in state) { - const sharedState = state as SharedState; - return sharedState.data.processedPointedDOMElement; - } - - // Legacy format - return as-is - const legacyState = state as LegacySharedState; - return legacyState; + return state.data.processedPointedDOMElement; } - private async readState(): Promise { + private async readState(): Promise { try { const json = await fs.readFile(SharedStateService.SHARED_STATE_PATH, 'utf8'); return JSON.parse(json); diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 7547f3a..677e207 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -3,7 +3,6 @@ import { CSSProperties, ComponentInfo, RawPointedDOMElement, - TargetedElement, } from '@mcp-pointer/shared/types'; // Server-processed data (extracted & enhanced) @@ -33,16 +32,7 @@ export interface ProcessedPointedDOMElement { warnings?: string[]; } -// Version-specific data types -export interface StateDataV1 { - rawPointedDOMElement: TargetedElement; - processedPointedDOMElement: ProcessedPointedDOMElement; - metadata: { - receivedAt: string; - messageType: string; - }; -} - +// State data structure export interface StateDataV2 { rawPointedDOMElement: RawPointedDOMElement; processedPointedDOMElement: ProcessedPointedDOMElement; @@ -52,11 +42,8 @@ export interface StateDataV2 { }; } -// Storage format with versioned data +// Storage format export interface SharedState { - stateVersion: number; - data: StateDataV1 | StateDataV2; + stateVersion: 2; + data: StateDataV2; } - -// Legacy format alias -export type LegacySharedState = TargetedElement; diff --git a/packages/server/src/websocket-server.ts b/packages/server/src/websocket-server.ts deleted file mode 100644 index 5811869..0000000 --- a/packages/server/src/websocket-server.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { WebSocketServer } from 'ws'; -import { PointerMessage, PointerMessageType, TargetedElement } from '@mcp-pointer/shared/types'; -import { config } from './config'; -import logger from './logger'; - -export default class PointerWebSocketServer { - private wss: WebSocketServer | null = null; - - private currentElement: TargetedElement | null = null; - - private port: number; - - constructor(port: number = config.websocket.port) { - this.port = port; - } - - public start(): Promise { - return new Promise((resolve, reject) => { - this.wss = new WebSocketServer({ port: this.port }); - - this.wss.on('connection', (ws) => { - logger.info('πŸ‘† Browser extension connected to WebSocket server'); - - ws.on('message', (data) => { - try { - const message: PointerMessage = JSON.parse(data.toString()); - logger.info('πŸ“¨ Received message from browser:', message.type); - this.handleMessage(message); - } catch (error) { - logger.error('Failed to parse message:', error); - } - }); - - ws.on('close', () => { - logger.info('πŸ‘† Browser extension disconnected from WebSocket server'); - }); - }); - - this.wss.on('listening', () => { - logger.info(`WebSocket server listening on port ${this.port}`); - resolve(); - }); - - this.wss.on('error', reject); - }); - } - - private handleMessage(message: PointerMessage): void { - if (message.type === PointerMessageType.LEGACY_ELEMENT_SELECTED && message.data) { - this.currentElement = message.data as TargetedElement; - } - } - - public getCurrentElement(): TargetedElement | null { - return this.currentElement; - } - - public stop(): void { - if (this.wss) { - this.wss.close(); - this.wss = null; - } - } -} From ca7a5163cd884d11db7a330dd1faff99a6a5647a Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Wed, 1 Oct 2025 14:36:16 -0700 Subject: [PATCH 12/13] docs: add v0.6.0 changeset and update architecture documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add changeset for minor version bump (0.5.2 β†’ 0.6.0) - Update CONTRIBUTING.md project structure to reflect current architecture - Document services/ and utils/ directories - Remove references to deleted files --- .changeset/remove-legacy-support.md | 14 ++++++++++ CONTRIBUTING.md | 42 ++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 .changeset/remove-legacy-support.md diff --git a/.changeset/remove-legacy-support.md b/.changeset/remove-legacy-support.md new file mode 100644 index 0000000..bc25635 --- /dev/null +++ b/.changeset/remove-legacy-support.md @@ -0,0 +1,14 @@ +--- +"@mcp-pointer/server": minor +"@mcp-pointer/shared": minor +--- + +**Architecture Cleanup & Improvements** + +- **Server**: Store full CSS properties in `cssProperties` instead of filtering to 5 properties +- **Server**: Remove LEGACY_ELEMENT_SELECTED support - only DOM_ELEMENT_POINTED is now supported +- **Server**: Delete unused files (`mcp-handler.ts`, `websocket-server.ts`) +- **Server**: Simplify types - remove StateDataV1 and LegacySharedState +- **Server**: Dynamic CSS filtering now happens on-the-fly during MCP tool calls based on cssLevel parameter + +This enables full CSS details to be accessible without re-pointing to elements, with filtering applied server-side based on tool parameters. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44677be..c02b016 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,9 +62,16 @@ packages/ β”œβ”€β”€ server/ # @mcp-pointer/server - MCP Server (TypeScript) β”‚ β”œβ”€β”€ src/ β”‚ β”‚ β”œβ”€β”€ start.ts # Main server entry point -β”‚ β”‚ β”œβ”€β”€ cli.ts # Command line interface -β”‚ β”‚ β”œβ”€β”€ websocket-server.ts -β”‚ β”‚ └── mcp-handler.ts +β”‚ β”‚ β”œβ”€β”€ cli.ts # Command line interface +β”‚ β”‚ β”œβ”€β”€ message-handler.ts # Message routing & state building +β”‚ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ websocket-service.ts # WebSocket with leader election +β”‚ β”‚ β”‚ β”œβ”€β”€ mcp-service.ts # MCP protocol handler +β”‚ β”‚ β”‚ β”œβ”€β”€ element-processor.ts # Rawβ†’Processed conversion +β”‚ β”‚ β”‚ └── shared-state-service.ts # State persistence +β”‚ β”‚ └── utils/ +β”‚ β”‚ β”œβ”€β”€ dom-extractor.ts # HTML parsing utilities +β”‚ β”‚ └── element-detail.ts # Dynamic CSS/text filtering β”‚ β”œβ”€β”€ dist/ β”‚ β”‚ └── cli.cjs # Bundled standalone CLI β”‚ └── package.json @@ -73,15 +80,17 @@ packages/ β”‚ β”œβ”€β”€ src/ β”‚ β”‚ β”œβ”€β”€ background.ts # Service worker β”‚ β”‚ β”œβ”€β”€ content.ts # Element selection -β”‚ β”‚ └── element-sender-service.ts +β”‚ β”‚ └── services/ +β”‚ β”‚ └── element-sender-service.ts # WebSocket client β”‚ β”œβ”€β”€ dev/ # Development build (with logging) β”‚ β”œβ”€β”€ dist/ # Production build (minified) β”‚ └── manifest.json β”‚ └── shared/ # @mcp-pointer/shared - Shared TypeScript types β”œβ”€β”€ src/ - β”‚ β”œβ”€β”€ Logger.ts - β”‚ └── types.ts + β”‚ β”œβ”€β”€ logger.ts + β”‚ β”œβ”€β”€ types.ts + β”‚ └── detail.ts # CSS/text detail level constants └── package.json ``` @@ -119,9 +128,16 @@ packages/ β”œβ”€β”€ server/ # @mcp-pointer/server - MCP Server (TypeScript) β”‚ β”œβ”€β”€ src/ β”‚ β”‚ β”œβ”€β”€ start.ts # Main server entry point -β”‚ β”‚ β”œβ”€β”€ cli.ts # Command line interface -β”‚ β”‚ β”œβ”€β”€ websocket-server.ts -β”‚ β”‚ └── mcp-handler.ts +β”‚ β”‚ β”œβ”€β”€ cli.ts # Command line interface +β”‚ β”‚ β”œβ”€β”€ message-handler.ts # Message routing & state building +β”‚ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ websocket-service.ts # WebSocket with leader election +β”‚ β”‚ β”‚ β”œβ”€β”€ mcp-service.ts # MCP protocol handler +β”‚ β”‚ β”‚ β”œβ”€β”€ element-processor.ts # Rawβ†’Processed conversion +β”‚ β”‚ β”‚ └── shared-state-service.ts # State persistence +β”‚ β”‚ └── utils/ +β”‚ β”‚ β”œβ”€β”€ dom-extractor.ts # HTML parsing utilities +β”‚ β”‚ └── element-detail.ts # Dynamic CSS/text filtering β”‚ β”œβ”€β”€ dist/ β”‚ β”‚ └── cli.cjs # Bundled standalone CLI β”‚ └── package.json @@ -130,15 +146,17 @@ packages/ β”‚ β”œβ”€β”€ src/ β”‚ β”‚ β”œβ”€β”€ background.ts # Service worker β”‚ β”‚ β”œβ”€β”€ content.ts # Element selection -β”‚ β”‚ └── element-sender-service.ts +β”‚ β”‚ └── services/ +β”‚ β”‚ └── element-sender-service.ts # WebSocket client β”‚ β”œβ”€β”€ dev/ # Development build (with logging) β”‚ β”œβ”€β”€ dist/ # Production build (minified) β”‚ └── manifest.json β”‚ └── shared/ # @mcp-pointer/shared - Shared TypeScript types β”œβ”€β”€ src/ - β”‚ β”œβ”€β”€ Logger.ts - β”‚ └── types.ts + β”‚ β”œβ”€β”€ logger.ts + β”‚ β”œβ”€β”€ types.ts + β”‚ └── detail.ts # CSS/text detail level constants └── package.json ``` From 4fb84d0a297221513f326113a12f0db6e0c4cc68 Mon Sep 17 00:00:00 2001 From: chickenfriedricenice Date: Thu, 2 Oct 2025 14:48:30 -0700 Subject: [PATCH 13/13] fix(extension): return true from message listener to keep channel open - Chrome extensions require return true when using sendResponse asynchronously - Without it, message channel closes immediately causing chrome.runtime.lastError - Fixes bug introduced in commit 155a07c where refactor removed the return statement --- packages/chrome-extension/src/background.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/chrome-extension/src/background.ts b/packages/chrome-extension/src/background.ts index 46eda0d..31c0d69 100644 --- a/packages/chrome-extension/src/background.ts +++ b/packages/chrome-extension/src/background.ts @@ -67,6 +67,7 @@ chrome.runtime.onMessage ); sendResponse({ success: true }); + return true; // Keep message channel open for async response } });