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 ``` 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 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/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 } }); 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; 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/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/__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/__tests__/utils/element-detail.test.ts b/packages/server/src/__tests__/utils/element-detail.test.ts new file mode 100644 index 0000000..a858bd0 --- /dev/null +++ b/packages/server/src/__tests__/utils/element-detail.test.ts @@ -0,0 +1,108 @@ +import { + normalizeDetailParameters, + normalizeCssLevel, + normalizeTextDetail, + shapeElementForDetail, +} from '../../utils/element-detail'; +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)', + marginTop: '10px', + paddingLeft: '5px', + }, + 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', () => { + 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 = createMockProcessedElement(); + const shaped = shapeElementForDetail(element, 'none', 0); + + expect(shaped.innerText).toBe(''); + expect(shaped.textContent).toBeUndefined(); + expect(shaped.cssProperties).toBeUndefined(); + }); + + it('returns visible text and level 1 css subset', () => { + 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'); + 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 = createMockProcessedElement(); + const shaped = shapeElementForDetail(element, 'full', 3); + + expect(shaped.cssProperties).toEqual(element.cssComputed); + expect(shaped.textContent).toBe(element.textContent); + }); + }); +}); diff --git a/packages/server/src/mcp-handler.ts b/packages/server/src/mcp-handler.ts deleted file mode 100644 index 60a82cf..0000000 --- a/packages/server/src/mcp-handler.ts +++ /dev/null @@ -1,98 +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 type PointerWebSocketServer from './websocket-server'; - -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 from the browser extension, in order to let you see a specific element the user is showing you on his/her the browser.', - inputSchema: { - type: 'object', - properties: {}, - required: [], - }, - }, - ], - }; - } - - private async handleCallTool(request: any) { - if (request.params.name === MCPToolName.GET_POINTED_ELEMENT) { - return this.getTargetedElement(); - } - - throw new Error(`Unknown tool: ${request.params.name}`); - } - - private getTargetedElement() { - 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.', - }, - ], - }; - } - - return { - content: [ - { - type: 'text', - text: JSON.stringify(element, 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/element-processor.ts b/packages/server/src/services/element-processor.ts index 4c446e4..263968e 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, @@ -65,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 { diff --git a/packages/server/src/services/mcp-service.ts b/packages/server/src/services/mcp-service.ts index 33885eb..7e4fa12 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,17 @@ 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), }, ], }; 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 4106191..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) @@ -21,24 +20,19 @@ 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[]; } -// 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; @@ -48,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/utils/element-detail.ts b/packages/server/src/utils/element-detail.ts new file mode 100644 index 0000000..3b40480 --- /dev/null +++ b/packages/server/src/utils/element-detail.ts @@ -0,0 +1,180 @@ +import { + CSSDetailLevel, + CSSProperties, + DEFAULT_CSS_LEVEL, + DEFAULT_TEXT_DETAIL, + TextDetailLevel, +} 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; + 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 resolveTextContent( + element: ProcessedPointedDOMElement, + detail: TextDetailLevel, +): string | undefined { + if (detail === 'none') { + return undefined; + } + + if (detail === 'visible') { + return element.innerText; + } + + // 'full' - return textContent if available, otherwise innerText + return element.textContent ?? element.innerText; +} + +function buildCssProperties( + element: ProcessedPointedDOMElement, + 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: ProcessedPointedDOMElement, + detail: TextDetailLevel, + cssLevel: CSSDetailLevel, +): ProcessedPointedDOMElement { + const resolvedText = resolveTextContent(element, detail); + const textContent = detail === 'full' ? element.textContent : undefined; + const cssProperties = buildCssProperties(element, cssLevel); + + const shaped: ProcessedPointedDOMElement = { + selector: element.selector, + tagName: element.tagName, + id: element.id, + classes: [...element.classes], + attributes: { ...element.attributes }, + position: { ...element.position }, + componentInfo: element.componentInfo ? { ...element.componentInfo } : undefined, + timestamp: element.timestamp, + url: element.url, + innerText: resolvedText ?? '', + warnings: element.warnings, + }; + + if (textContent !== undefined) { + shaped.textContent = textContent; + } + + if (cssProperties) { + shaped.cssProperties = cssProperties; + } + + return shaped; +} diff --git a/packages/server/src/websocket-server.ts b/packages/server/src/websocket-server.ts deleted file mode 100644 index 8ae0b36..0000000 --- a/packages/server/src/websocket-server.ts +++ /dev/null @@ -1,66 +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.ELEMENT_SELECTED && message.data) { - this.currentElement = message.data as TargetedElement; - } else if (message.type === PointerMessageType.ELEMENT_CLEARED) { - this.currentElement = null; - } - } - - public getCurrentElement(): TargetedElement | null { - return this.currentElement; - } - - public stop(): void { - if (this.wss) { - this.wss.close(); - this.wss = null; - } - } -} 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 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;