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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 72 additions & 16 deletions mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,59 @@

MCP server that exposes CSS no-op detection rules as tools. Uses Playwright to open pages, extract computed styles from all elements, and analyze them with the rules engine.

This is a long-running stdio server — it stays running and communicates over stdin/stdout using the MCP protocol. It is designed to be launched by an MCP client (e.g., Claude Code, Cursor), not run interactively.

## Prerequisites

- Node.js 24+ and pnpm 10+ (managed via `.mise.toml` in repo root)
- Run `pnpm install` from the workspace root (not from `mcp-server/` directly)
Playwright requires a Chromium browser binary. Install it once before first use:

```bash
npx playwright install chromium
```

If the browser is missing, the server will print a helpful error message with this command.

## Quick Start (npx)

```bash
npx css-noop-checker-mcp
```

### Claude Code

Add the MCP server to Claude Code:

```bash
claude mcp add --transport stdio css-noop-checker-mcp -- npx css-noop-checker-mcp
```

Or add it to your project's `.mcp.json`:

```json
{
"mcpServers": {
"css-noop-checker": {
"command": "npx",
"args": ["css-noop-checker-mcp"]
}
}
}
```

### Cursor / VS Code

Add to `.cursor/mcp.json` or VS Code MCP settings:

```json
{
"mcpServers": {
"css-noop-checker": {
"command": "npx",
"args": ["css-noop-checker-mcp"]
}
}
}
```

## Tools

Expand All @@ -17,30 +66,37 @@ MCP server that exposes CSS no-op detection rules as tools. Uses Playwright to o

All URL parameters require `http:` or `https:` scheme. Private/internal addresses are rejected (SSRF protection).

## Setup
## Development

### This project only
### From the monorepo

The `.mcp.json` at the project root auto-registers the server. Just start Claude Code in this repository.
The `.mcp.json` at the project root registers the server for development via `start.sh`. Just start Claude Code in this repository.

### All projects (user scope)
To run directly with tsx (no build step):

```bash
pnpm start
```

Or register the start script for user-wide access:

```bash
claude mcp add --transport stdio --scope user css-noop-checker -- /path/to/css-noop-checker/mcp-server/start.sh
```

`start.sh` handles `cd` into the repository and launches `npx tsx`. The `--scope user` flag stores the config in `~/.claude.json`, making it available from any project.
### Building

### Verify
```bash
cd mcp-server
pnpm build
```

Run `/mcp` inside Claude Code and confirm `css-noop-checker` shows as connected.
This bundles the rules engine and extraction helpers into a self-contained `dist/index.js`. Runtime dependencies (`playwright`, `@modelcontextprotocol/sdk`, `zod`) remain external.

## Dependencies

| Package | Purpose |
| --------------------------- | ------------------------------------------------------------------------- |
| `@modelcontextprotocol/sdk` | MCP protocol server implementation |
| `playwright` | Browser automation for page analysis (downloads Chromium on first launch) |
| `zod` | Input schema validation for tool parameters |

The rules engine (`src/rules/`) and extraction helpers (`e2e/helpers/`) are imported directly from the parent workspace — not published as separate packages.
| Package | Purpose |
| --------------------------- | --------------------------------------------------------------------------------- |
| `@modelcontextprotocol/sdk` | MCP protocol server implementation |
| `playwright` | Browser automation for page analysis (requires `npx playwright install chromium`) |
| `zod` | Input schema validation for tool parameters |
18 changes: 16 additions & 2 deletions mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"name": "css-noop-checker-mcp",
"version": "0.0.1",
"private": true,
"version": "0.1.0",
"description": "MCP server for CSS no-op detection via Playwright",
"keywords": [
"css",
Expand All @@ -20,8 +19,19 @@
"url": "https://github.com/purupurupu/css-noop-checker.git",
"directory": "mcp-server"
},
"bin": {
"css-noop-checker-mcp": "./dist/index.js"
},
"files": [
"dist"
],
"type": "module",
"exports": {
".": "./dist/index.js"
},
"scripts": {
"build": "tsup",
"prepublishOnly": "tsup",
"start": "tsx src/index.ts"
},
"dependencies": {
Expand All @@ -31,7 +41,11 @@
},
"devDependencies": {
"@types/node": "^24.10.1",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "~5.9.3"
},
"engines": {
"node": ">=24"
}
}
59 changes: 33 additions & 26 deletions mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
#!/usr/bin/env tsx
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { chromium } from 'playwright';
import type { Browser } from 'playwright';
import type { Browser, Page } from 'playwright';
import { analyzeElement } from '../../src/rules/engine.ts';
import { getRules } from '../../src/rules/registry.ts';
import {
Expand All @@ -12,6 +11,8 @@ import {
} from '../../e2e/helpers/extract-element-data.ts';
import { validateUrl } from './url-validation.ts';

declare const __PKG_VERSION__: string;

const MAX_SELECTOR_LENGTH = 500;

function mcpError(message: string) {
Expand All @@ -30,8 +31,16 @@ function mcpSuccess(data: unknown) {
// Persistent browser instance with lazy init and launch-race guard
let browserInstance: Browser | null = null;
let launchPromise: Promise<Browser> | null = null;
let shuttingDown = false;

function isBrowserNotInstalledError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message;
return msg.includes("Executable doesn't exist") || msg.includes('PLAYWRIGHT_BROWSERS_PATH');
}

async function getBrowser(): Promise<Browser> {
if (shuttingDown) throw new Error('Server is shutting down');
if (browserInstance?.isConnected()) return browserInstance;
if (launchPromise) return launchPromise;
launchPromise = chromium.launch().then(
Expand All @@ -42,23 +51,29 @@ async function getBrowser(): Promise<Browser> {
},
(err) => {
launchPromise = null;
if (isBrowserNotInstalledError(err)) {
const helpMsg =
'Chromium browser is not installed for Playwright.\n' +
'Run the following command to install it:\n\n' +
' npx playwright install chromium\n';
console.error(helpMsg);
throw new Error(helpMsg);
}
console.error('Failed to launch browser:', err);
throw err;
},
);
return launchPromise;
}

let shuttingDown = false;

async function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
// Brief grace period lets in-flight page.evaluate() calls settle
// before we pull the browser out from under them.
await new Promise((r) => setTimeout(r, 500));
try {
if (browserInstance?.isConnected()) {
// Brief grace period lets in-flight page.evaluate() calls settle
// before we pull the browser out from under them.
await new Promise((r) => setTimeout(r, 500));
await browserInstance.close();
}
} catch (err) {
Expand All @@ -67,26 +82,20 @@ async function shutdown() {
browserInstance = null;
}

process.on('SIGINT', async () => {
try {
await shutdown();
} catch (err) {
console.error('Error during SIGINT shutdown:', err);
}
process.exit(0);
});
process.on('SIGTERM', async () => {
try {
await shutdown();
} catch (err) {
console.error('Error during SIGTERM shutdown:', err);
}
process.exit(0);
});
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, async () => {
try {
await shutdown();
} catch (err) {
console.error(`Error during ${signal} shutdown:`, err);
}
process.exit(0);
});
}

const server = new McpServer({
name: 'css-noop-checker',
version: '0.0.1',
version: typeof __PKG_VERSION__ !== 'undefined' ? __PKG_VERSION__ : '0.0.0-dev',
});

server.tool('list_rules', 'List all available CSS no-op detection rules', async () => {
Expand All @@ -103,8 +112,6 @@ server.tool('list_rules', 'List all available CSS no-op detection rules', async
}
});

import type { Page } from 'playwright';

type McpResult = ReturnType<typeof mcpSuccess> | ReturnType<typeof mcpError>;

async function withPage(
Expand Down
25 changes: 25 additions & 0 deletions mcp-server/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'tsup';

const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));

export default defineConfig({
entry: ['src/index.ts'],
format: 'esm',
target: 'node24',
platform: 'node',
outDir: 'dist',
clean: true,
// Override tsup's automatic externalization for workspace-linked paths.
// Forces ../src/rules/ and ../e2e/helpers/ to be inlined into the bundle.
noExternal: [/^\.\.\//],
define: {
__PKG_VERSION__: JSON.stringify(pkg.version),
},
banner: {
js: '#!/usr/bin/env node',
},
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"type": "module",
"scripts": {
"dev": "vite build --watch",
"build": "tsc -b && tsc -p mcp-server/tsconfig.json --noEmit && vite build",
"build": "tsc -b && tsc -p mcp-server/tsconfig.json --noEmit && vite build && pnpm --filter css-noop-checker-mcp build",
"lint": "oxlint .",
"fmt": "oxfmt --write .",
"fmt:check": "oxfmt --check .",
Expand Down
Loading
Loading