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..ac8cf1a 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`: `0 | 1 | 2` (default `2`) controls how much text to include (`0 = none`, `1 = visible text only`, `2 = visible + hidden`). + - `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..0067bc8 100644 --- a/packages/chrome-extension/src/utils/element.ts +++ b/packages/chrome-extension/src/utils/element.ts @@ -1,218 +1,9 @@ // Disable ESLint rule for underscore dangle usage in this file (React internals) /* eslint-disable no-underscore-dangle */ -import { - ComponentInfo, CSSProperties, ElementPosition, TargetedElement, RawPointedDOMElement, -} from '@mcp-pointer/shared/types'; +import { RawPointedDOMElement } from '@mcp-pointer/shared/types'; import logger from './logger'; -export interface ReactSourceInfo { - fileName: string; - lineNumber?: number; - columnNumber?: number; -} - -/** - * Get source file information from a DOM element's React component - */ -export function getSourceFromElement(element: HTMLElement): ReactSourceInfo | null { - // Find React Fiber key - const fiberKey = Object.keys(element).find((key) => key.startsWith('__reactFiber$') - || key.startsWith('__reactInternalInstance$')); - - if (!fiberKey) return null; - - const fiber = (element as any)[fiberKey]; - if (!fiber) return null; - - // Walk up fiber tree to find component fiber (skip DOM fibers) - let componentFiber = fiber; - while (componentFiber && typeof componentFiber.type === 'string') { - componentFiber = componentFiber.return; - } - - if (!componentFiber) return null; - - // Try multiple source locations (React version differences) - // React 18: _debugSource - if (componentFiber._debugSource) { - return { - fileName: componentFiber._debugSource.fileName, - lineNumber: componentFiber._debugSource.lineNumber, - columnNumber: componentFiber._debugSource.columnNumber, - }; - } - - // React 19: _debugInfo (often null) - if (componentFiber._debugInfo) { - return componentFiber._debugInfo; - } - - // Babel plugin: __source on element type - if (componentFiber.elementType?.__source) { - return { - fileName: componentFiber.elementType.__source.fileName, - lineNumber: componentFiber.elementType.__source.lineNumber, - columnNumber: componentFiber.elementType.__source.columnNumber, - }; - } - - // Alternative: _owner chain - if (componentFiber._debugOwner?._debugSource) { - return { - fileName: componentFiber._debugOwner._debugSource.fileName, - lineNumber: componentFiber._debugOwner._debugSource.lineNumber, - columnNumber: componentFiber._debugOwner._debugSource.columnNumber, - }; - } - - // Check pendingProps for __source - if (componentFiber.pendingProps?.__source) { - return { - fileName: componentFiber.pendingProps.__source.fileName, - lineNumber: componentFiber.pendingProps.__source.lineNumber, - columnNumber: componentFiber.pendingProps.__source.columnNumber, - }; - } - - return null; -} - -/** - * Extract React Fiber information from an element - */ -export function getReactFiberInfo(element: HTMLElement): ComponentInfo | undefined { - try { - // Use comprehensive source detection - const sourceInfo = getSourceFromElement(element); - - // Also get component name - const fiberKey = Object.keys(element).find((key) => key.startsWith('__reactFiber$') - || key.startsWith('__reactInternalInstance$')); - - if (fiberKey) { - const fiber = (element as any)[fiberKey]; - if (fiber) { - // Find component fiber - let componentFiber = fiber; - while (componentFiber && typeof componentFiber.type === 'string') { - componentFiber = componentFiber.return; - } - - if (componentFiber && componentFiber.type && typeof componentFiber.type === 'function') { - const componentName = componentFiber.type.displayName - || componentFiber.type.name - || 'Unknown'; - - let sourceFile: string | undefined; - if (sourceInfo) { - const fileName = sourceInfo.fileName.split('/').pop() || sourceInfo.fileName; - sourceFile = sourceInfo.lineNumber - ? `${fileName}:${sourceInfo.lineNumber}` - : fileName; - } - - const result = { - name: componentName, - sourceFile, - framework: 'react' as const, - }; - - logger.debug('🧬 Found React Fiber info:', result); - return result; - } - } - } - - return undefined; - } catch (error) { - logger.error('🚨 Error extracting Fiber info:', error); - return undefined; - } -} - -/** - * Extract all attributes from an HTML element - */ -export function getElementAttributes(element: HTMLElement): Record { - const attributes: Record = {}; - for (let i = 0; i < element.attributes.length; i += 1) { - const attr = element.attributes[i]; - attributes[attr.name] = attr.value; - } - return attributes; -} - -/** - * Generate a CSS selector for an element - */ -export function generateSelector(element: HTMLElement): string { - let selector = element.tagName.toLowerCase(); - if (element.id) selector += `#${element.id}`; - if (element.className) { - const classNameStr = typeof element.className === 'string' - ? element.className - : (element.className as any).baseVal || ''; - const classes = classNameStr.split(' ').filter((c: string) => c.trim()); - if (classes.length > 0) selector += `.${classes.join('.')}`; - } - return selector; -} - -/** - * Get element position relative to the page - */ -export function getElementPosition(element: HTMLElement): ElementPosition { - const rect = element.getBoundingClientRect(); - return { - x: rect.left + window.scrollX, - y: rect.top + window.scrollY, - width: rect.width, - height: rect.height, - }; -} - -/** - * 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 - */ -export function getElementClasses(element: HTMLElement): string[] { - if (!element.className) return []; - const classNameStr = typeof element.className === 'string' - ? element.className - : (element.className as any).baseVal || ''; - return classNameStr.split(' ').filter((c: string) => c.trim()); -} - -export function adaptTargetToElement(element: HTMLElement): TargetedElement { - return { - 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), - componentInfo: getReactFiberInfo(element), - timestamp: Date.now(), - url: window.location.href, - }; -} - /** * Extract raw React Fiber from an element (if present) */ diff --git a/packages/chrome-extension/src/utils/types.ts b/packages/chrome-extension/src/utils/types.ts deleted file mode 100644 index 29cca88..0000000 --- a/packages/chrome-extension/src/utils/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ComponentInfo, ElementPosition, CSSProperties } from './element'; - -export interface TargetedElement { - selector: string; - tagName: string; - id?: string; - classes: string[]; - innerText: string; - attributes: Record; - position: ElementPosition; - cssProperties: CSSProperties; - 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..5aef1a2 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, SharedStateData, ProcessedPointedDOMElement, } from '../../types'; export const createProcessedElement = ( @@ -39,34 +39,10 @@ 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 = ( +export const createSharedState = ( rawOverrides: Partial = {}, processedOverrides: Partial = {}, ): SharedState => ({ - stateVersion: 2, data: { rawPointedDOMElement: createRawElement(rawOverrides), processedPointedDOMElement: createProcessedElement(processedOverrides), @@ -74,20 +50,5 @@ export const createStateV2 = ( receivedAt: '2023-01-01T00:00:00.000Z', messageType: PointerMessageType.DOM_ELEMENT_POINTED, }, - } 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, + } as SharedStateData, }); 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..0e5a216 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 { createSharedState } from '../factories/shared-state-factory'; jest.mock('../../logger', () => ({ debug: jest.fn(), @@ -29,32 +29,30 @@ describe('SharedStateService', () => { describe('saveState', () => { it('writes state to file', async () => { - const state = createStateV2(); + const state = createSharedState(); await service.saveState(state); const content = await fs.readFile(testPath, 'utf8'); const parsed = JSON.parse(content); - expect(parsed.stateVersion).toBe(state.stateVersion); expect(parsed.data.processedPointedDOMElement).toEqual(state.data.processedPointedDOMElement); }); it('overwrites corrupted file', async () => { await fs.writeFile(testPath, 'invalid json'); - const state = createStateV1(); + const state = createSharedState(); await service.saveState(state); const content = await fs.readFile(testPath, 'utf8'); const parsed = JSON.parse(content); - expect(parsed.stateVersion).toBe(state.stateVersion); expect(parsed.data.processedPointedDOMElement).toEqual(state.data.processedPointedDOMElement); }); }); describe('getPointedElement', () => { - it('returns processed element from v2 state', async () => { - const state = createStateV2(); + it('returns processed element from state', async () => { + const state = createSharedState(); await fs.writeFile(testPath, JSON.stringify(state)); const result = await service.getPointedElement(); @@ -62,24 +60,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..e9500f4 100644 --- a/packages/server/src/__tests__/test-helpers.ts +++ b/packages/server/src/__tests__/test-helpers.ts @@ -1,7 +1,7 @@ import fs from 'fs/promises'; import path from 'path'; import os from 'os'; -import { TargetedElement } from '@mcp-pointer/shared/types'; +import { TargetedElement, TextDetailLevel, CSSDetailLevel } from '@mcp-pointer/shared/types'; // Test constants - use a temp directory that works in Jest export const TEST_MCP_POINTER_PORT = 7008; @@ -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: TextDetailLevel.FULL, + textVariants: { + visible: text, + full: text, + }, attributes: { 'data-test': 'true' }, position: { x: 100, y: 200, width: 300, height: 50, }, + cssLevel: CSSDetailLevel.BASIC, 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..99beee0 --- /dev/null +++ b/packages/server/src/__tests__/utils/element-detail.test.ts @@ -0,0 +1,112 @@ +import { CSSDetailLevel, TextDetailLevel } from '@mcp-pointer/shared/types'; +import { + normalizeDetailParameters, + normalizeCssLevel, + normalizeTextDetail, + serializeElement, +} 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, + }, + 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(TextDetailLevel.FULL); + expect(normalizeTextDetail('VISIBLE')).toBe(TextDetailLevel.VISIBLE); + expect(normalizeTextDetail('invalid', TextDetailLevel.VISIBLE)).toBe(TextDetailLevel.VISIBLE); + }); + }); + + describe('normalizeCssLevel', () => { + it('coerces numeric strings and falls back to default', () => { + expect(normalizeCssLevel('2')).toBe(CSSDetailLevel.BOX_MODEL); + expect(normalizeCssLevel('not-a-number', CSSDetailLevel.FULL)).toBe(CSSDetailLevel.FULL); + expect(normalizeCssLevel(undefined)).toBe(CSSDetailLevel.BASIC); + }); + }); + + describe('normalizeDetailParameters', () => { + it('applies defaults when params are missing', () => { + expect(normalizeDetailParameters(undefined)).toEqual({ + textDetail: TextDetailLevel.FULL, + cssLevel: CSSDetailLevel.BASIC, + }); + }); + + it('normalizes provided params', () => { + expect(normalizeDetailParameters({ textDetail: 'visible', cssLevel: '0' })).toEqual({ + textDetail: TextDetailLevel.VISIBLE, + cssLevel: CSSDetailLevel.NONE, + }); + }); + }); + + describe('serializeElement', () => { + it('omits text and css when levels request none', () => { + const element = createMockProcessedElement(); + const shaped = serializeElement( + element, + TextDetailLevel.NONE, + CSSDetailLevel.NONE, + ); + + 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 = serializeElement( + element, + TextDetailLevel.VISIBLE, + CSSDetailLevel.BASIC, + ); + + 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 = serializeElement( + element, + TextDetailLevel.FULL, + CSSDetailLevel.FULL, + ); + + 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..565cf2b 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, SharedStateData } from './types'; function buildMetadata(messageType: string) { const now = new Date().toISOString(); @@ -13,43 +13,22 @@ 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); - const stateData: StateDataV2 = { + const stateData: SharedStateData = { rawPointedDOMElement: raw, processedPointedDOMElement: processed, metadata: buildMetadata(type), }; return { - stateVersion: 2, data: stateData, }; } @@ -59,15 +38,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..618b464 100644 --- a/packages/server/src/services/element-processor.ts +++ b/packages/server/src/services/element-processor.ts @@ -1,6 +1,6 @@ // Simple safe getter function (replaces lodash.get) import { - RawPointedDOMElement, ElementPosition, CSSProperties, ComponentInfo, + RawPointedDOMElement, ElementPosition, ComponentInfo, } from '@mcp-pointer/shared/types'; import { ProcessedPointedDOMElement } from '../types'; import { extractFromHTML, generateSelector } from '../utils/dom-extractor'; @@ -26,13 +26,14 @@ 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), url: raw.url, 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, @@ -62,18 +63,6 @@ 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'), - }; - } - private getComponentInfo(reactFiber?: any): ComponentInfo | undefined { if (!reactFiber) return undefined; diff --git a/packages/server/src/services/mcp-service.ts b/packages/server/src/services/mcp-service.ts index 33885eb..f0b824e 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, + serializeElement, + 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 (0 none | 1 visible | 2 full) and cssLevel (0-3).', inputSchema: { type: 'object', - properties: {}, + properties: { + textDetail: { + type: 'integer', + enum: [...TEXT_DETAIL_OPTIONS], + description: 'Controls how much text is returned. 2 (default) includes hidden text fallback, 1 uses only rendered text, 0 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 = serializeElement( + 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..f610fa2 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,25 +20,35 @@ export interface ProcessedPointedDOMElement { url: string; timestamp: string; // ISO format - // Optional processing - cssProperties?: CSSProperties; + // Full CSS data for shaping + 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; - }; +export interface SerializedDOMElement { + selector: string; + tagName: string; + id?: string; + classes: string[]; + attributes: Record; + position: ElementPosition; + url: string; + timestamp: string; + innerText: string; + textContent?: string; + cssProperties?: CSSProperties; + componentInfo?: ComponentInfo; + warnings?: string[]; } -export interface StateDataV2 { +// State data structure +export interface SharedStateData { rawPointedDOMElement: RawPointedDOMElement; processedPointedDOMElement: ProcessedPointedDOMElement; metadata: { @@ -48,11 +57,7 @@ export interface StateDataV2 { }; } -// Storage format with versioned data +// Storage format export interface SharedState { - stateVersion: number; - data: StateDataV1 | StateDataV2; + data: SharedStateData; } - -// 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..899f86d --- /dev/null +++ b/packages/server/src/utils/element-detail.ts @@ -0,0 +1,192 @@ +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, SerializedDOMElement } 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; +} + +const TEXT_DETAIL_ALIAS_MAP: Record = { + full: TextDetailLevel.FULL, + visible: TextDetailLevel.VISIBLE, + none: TextDetailLevel.NONE, +}; + +function fromTextDetailAlias(value: string): TextDetailLevel | null { + const normalized = value.trim().toLowerCase(); + return TEXT_DETAIL_ALIAS_MAP[normalized] ?? null; +} + +export function normalizeTextDetail( + detail: unknown, + fallback: TextDetailLevel = DEFAULT_TEXT_DETAIL, +): TextDetailLevel { + if (isValidTextDetail(detail)) { + return detail; + } + + if (typeof detail === 'string') { + const alias = fromTextDetailAlias(detail); + if (alias !== null) { + return alias; + } + + const parsed = toNumber(detail); + if (parsed !== null && isValidTextDetail(parsed)) { + return parsed; + } + } + + 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 === TextDetailLevel.NONE) { + return undefined; + } + + if (detail === TextDetailLevel.VISIBLE) { + return element.innerText; + } + + // Full detail returns textContent if available, otherwise falls back to innerText + return element.textContent ?? element.innerText; +} + +function buildCssProperties( + element: ProcessedPointedDOMElement, + cssLevel: CSSDetailLevel, +): CSSProperties | undefined { + if (cssLevel === CSSDetailLevel.NONE) { + return undefined; + } + + if (cssLevel === CSSDetailLevel.FULL) { + if (element.cssComputed) { + return { ...element.cssComputed }; + } + + return undefined; + } + + const fields = CSS_LEVEL_FIELD_MAP[cssLevel]; + const cssProperties: CSSProperties = {}; + const source = element.cssComputed ?? {}; + + fields.forEach((property) => { + const value = source[property]; + if (value !== undefined) { + cssProperties[property] = value; + } + }); + + if (Object.keys(cssProperties).length > 0) { + return cssProperties; + } + + if (element.cssComputed) { + return { ...element.cssComputed }; + } + + return undefined; +} + +export function serializeElement( + element: ProcessedPointedDOMElement, + detail: TextDetailLevel, + cssLevel: CSSDetailLevel, +): SerializedDOMElement { + const resolvedText = resolveTextContent(element, detail); + const textContent = detail === TextDetailLevel.FULL ? element.textContent : undefined; + const cssProperties = buildCssProperties(element, cssLevel); + + const shaped: SerializedDOMElement = { + 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..d8e0409 --- /dev/null +++ b/packages/shared/src/detail.ts @@ -0,0 +1,84 @@ +import { CSSDetailLevel, TextDetailLevel } from './types'; + +function getEnumNumberValues>(enumObj: T): number[] { + return Object.values(enumObj).filter((value): value is number => typeof value === 'number'); +} + +export const TEXT_DETAIL_OPTIONS: readonly TextDetailLevel[] = Object.freeze( + getEnumNumberValues(TextDetailLevel) as TextDetailLevel[], +); + +export const CSS_DETAIL_OPTIONS: readonly CSSDetailLevel[] = Object.freeze( + getEnumNumberValues(CSSDetailLevel) as CSSDetailLevel[], +); + +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({ + [CSSDetailLevel.BASIC]: CSS_LEVEL_1_FIELDS, + [CSSDetailLevel.BOX_MODEL]: CSS_LEVEL_2_FIELDS, + [CSSDetailLevel.FULL]: [], +}); + +export function isValidTextDetail(detail: unknown): detail is TextDetailLevel { + return typeof detail === 'number' && (TEXT_DETAIL_OPTIONS as readonly number[]).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..8652515 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1,3 +1,25 @@ +export enum TextDetailLevel { + NONE = 0, + VISIBLE = 1, + FULL = 2, +} + +export enum CSSDetailLevel { + NONE = 0, + BASIC = 1, + BOX_MODEL = 2, + FULL = 3, +} + +export const DEFAULT_TEXT_DETAIL: TextDetailLevel = TextDetailLevel.FULL; + +export const DEFAULT_CSS_LEVEL: CSSDetailLevel = CSSDetailLevel.BASIC; + +export interface TextSnapshots { + visible: string; + full: string; +} + export interface ElementPosition { x: number; y: number; @@ -5,13 +27,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 +40,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;