genat-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # GenAT MCP
2
+
3
+ MCP server that exposes a **GenAT** tool: generate accessibility tests for a URL based on your project folder. It detects framework (TypeScript/Python, BDD, Page Object), calls an n8n GenAT workflow, and returns (or writes) generated test files.
4
+
5
+ Full setup (n8n workflow, services, usage) is in the [project README](../README.md).
6
+
7
+ ## Install
8
+
9
+ **If the package is published to npm** (otherwise you get 404):
10
+
11
+ ```bash
12
+ # Global (binary on PATH)
13
+ npm install -g genat-mcp
14
+
15
+ # Or local / project
16
+ npm install genat-mcp
17
+ ```
18
+
19
+ **Until published, or for development**, install from the repo:
20
+
21
+ ```bash
22
+ # From this repo root
23
+ cd mcp-genat && npm install
24
+ # Then in Cursor MCP use: node with args ["/path/to/n8n_playwright_tests/mcp-genat/index.js"]
25
+
26
+ # Or from another project (local path)
27
+ npm install /path/to/n8n_playwright_tests/mcp-genat
28
+ # Then use npx genat-mcp or node node_modules/genat-mcp/index.js
29
+ ```
30
+
31
+ ## Environment
32
+
33
+ | Variable | Required | Default | Description |
34
+ |--------------------|----------|---------|-------------|
35
+ | **N8N_WEBHOOK_URL** | No | `http://localhost:5678/webhook-test/webhook-genat` | n8n GenAT webhook URL. Set this if your n8n instance is elsewhere (e.g. `https://your-n8n-host/webhook-test/webhook-genat`). |
36
+
37
+ ## Cursor MCP config
38
+
39
+ **Using npx (local install):**
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "GenAT": {
45
+ "command": "npx",
46
+ "args": ["genat-mcp"]
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ **Using global install:**
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "GenAT": {
58
+ "command": "genat-mcp"
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ If the global binary is not on PATH, use the full path, e.g. `node $(npm root -g)/genat-mcp/index.js`.
65
+
66
+ ## Requirements
67
+
68
+ - **Node.js** 20+
69
+ - **n8n** with the [GenAT workflow](../workflows/genat-accessibility-tests.json) imported and activated (webhook path `webhook-test/webhook-genat`)
70
+ - **DOM Analyzer** and **Accessibility Analyzer** services running (see main README: `npm run services` from repo root)
71
+
72
+ ## Tool: GenAT
73
+
74
+ - **url** (string): Page URL to analyze for accessibility.
75
+ - **parentProjectFolder** (string): Path to the project root (TypeScript or Python Playwright).
76
+ - **writeToProject** (boolean, optional): If `true`, write generated files under `tests/accessibility/` in the project folder.
77
+
78
+ Returns JSON with `testCode`, `featureFile`, `stepDefCode`, `pageObjectCode` (and `_written` paths when writing).
79
+
80
+ ## Publishing to npm
81
+
82
+ Until the package is published, `npm install genat-mcp` returns 404. To publish:
83
+
84
+ 1. Replace `your-username` in `package.json` (`repository`, `homepage`, `bugs`) and in `server.json` if you use the MCP Registry.
85
+ 2. **npm requires 2FA or a granular access token with "bypass 2FA" enabled** to publish. Enable 2FA on your npm account (Account → "Require two-factor authentication for writes"), or create an [Automation / granular token](https://docs.npmjs.com/creating-and-viewing-access-tokens) with "bypass 2FA for publish" and use it: `npm login` (paste token as password) or set `NPM_CONFIG_//registry.npmjs.org/:_authToken=your-token`.
86
+ 3. From this directory: `npm publish` (or `npm publish --access public` for a scoped package).
87
+
88
+ After publishing, the "Install from npm" instructions above work.
89
+
90
+ ## Publishing to the MCP Registry
91
+
92
+ To list this server on the [MCP Registry](https://registry.modelcontextprotocol.io): use your GitHub identity in `package.json` and `server.json`, publish to npm (see above), install [mcp-publisher](https://github.com/modelcontextprotocol/registry/releases), run `mcp-publisher login github` and `mcp-publisher publish`.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Detect project framework from parent folder: language, BDD framework, Page Object pattern.
3
+ * Used by GenAT MCP before calling n8n.
4
+ */
5
+ import { readFileSync, readdirSync, existsSync } from 'fs';
6
+ import { join, resolve } from 'path';
7
+
8
+ const ALLOWED_BDD = ['cucumber', 'pytest-bdd', 'behave', 'none'];
9
+
10
+ /**
11
+ * @param {string} parentProjectFolder - absolute or relative path to project root
12
+ * @returns {{ scriptType: 'typescript'|'python', bddFramework: string, pageObject: boolean, projectSummary: string }}
13
+ */
14
+ export function detectFramework(parentProjectFolder) {
15
+ const root = resolve(parentProjectFolder);
16
+ if (!existsSync(root) || !readdirSync(root).length) {
17
+ return {
18
+ scriptType: 'typescript',
19
+ bddFramework: 'none',
20
+ pageObject: false,
21
+ projectSummary: 'Folder not found or empty; using defaults.',
22
+ };
23
+ }
24
+
25
+ let scriptType = 'typescript';
26
+ let bddFramework = 'none';
27
+ let pageObject = false;
28
+ const summaryParts = [];
29
+
30
+ // Language: package.json + ts/spec vs requirements.txt / py
31
+ const hasPackageJson = existsSync(join(root, 'package.json'));
32
+ const hasTsConfig = existsSync(join(root, 'tsconfig.json'));
33
+ const hasRequirements = existsSync(join(root, 'requirements.txt'));
34
+ const hasPyproject = existsSync(join(root, 'pyproject.toml'));
35
+
36
+ function hasExtension(dir, ext) {
37
+ try {
38
+ const files = walk(root, (f) => f.endsWith(ext));
39
+ return files.length > 0;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+ const hasTs = hasExtension(root, '.ts') || hasExtension(root, '.spec.ts');
45
+ const hasPy = hasExtension(root, '.py') || hasExtension(root, '_test.py');
46
+
47
+ if (hasPy && (hasRequirements || hasPyproject || hasPy)) {
48
+ scriptType = 'python';
49
+ summaryParts.push('Language: Python');
50
+ } else if (hasPackageJson || hasTsConfig || hasTs) {
51
+ scriptType = 'typescript';
52
+ summaryParts.push('Language: TypeScript/JavaScript');
53
+ }
54
+
55
+ // BDD: Cucumber (package.json), pytest-bdd, Behave
56
+ if (scriptType === 'typescript' && hasPackageJson) {
57
+ try {
58
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
59
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
60
+ if (deps['@cucumber/cucumber'] || deps['cucumber']) {
61
+ bddFramework = 'cucumber';
62
+ summaryParts.push('BDD: Cucumber');
63
+ }
64
+ } catch (_) {}
65
+ }
66
+ const hasFeatureFiles = hasExtension(root, '.feature');
67
+ if (scriptType === 'python') {
68
+ try {
69
+ if (hasRequirements) {
70
+ const req = readFileSync(join(root, 'requirements.txt'), 'utf8');
71
+ if (/pytest-bdd/i.test(req)) {
72
+ bddFramework = 'pytest-bdd';
73
+ summaryParts.push('BDD: pytest-bdd');
74
+ } else if (/behave/i.test(req)) {
75
+ bddFramework = 'behave';
76
+ summaryParts.push('BDD: Behave');
77
+ }
78
+ }
79
+ if (bddFramework === 'none' && existsSync(join(root, 'features'))) {
80
+ bddFramework = 'behave';
81
+ summaryParts.push('BDD: Behave (features/ present)');
82
+ }
83
+ if (bddFramework === 'none' && hasFeatureFiles) summaryParts.push('Found .feature files');
84
+ } catch (_) {}
85
+ }
86
+ if (scriptType === 'typescript' && hasFeatureFiles && bddFramework === 'none') {
87
+ summaryParts.push('Found .feature files (consider Cucumber)');
88
+ }
89
+
90
+ // Page Object: pages/ or *Page.ts / *_page.py
91
+ try {
92
+ if (existsSync(join(root, 'pages'))) {
93
+ pageObject = true;
94
+ summaryParts.push('Page Object: pages/ directory');
95
+ } else {
96
+ const pageTs = walk(root, (f) => f.endsWith('Page.ts') || f.endsWith('Page.tsx'));
97
+ const pagePy = walk(root, (f) => f.endsWith('_page.py') || f.endsWith('Page.py'));
98
+ if (pageTs.length || pagePy.length) {
99
+ pageObject = true;
100
+ summaryParts.push('Page Object: *Page.ts / *_page.py files');
101
+ }
102
+ }
103
+ } catch (_) {}
104
+
105
+ const projectSummary = summaryParts.length ? summaryParts.join('; ') : 'No framework hints detected.';
106
+
107
+ return {
108
+ scriptType,
109
+ bddFramework: ALLOWED_BDD.includes(bddFramework) ? bddFramework : 'none',
110
+ pageObject,
111
+ projectSummary,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * @param {string} dir
117
+ * @param {(f: string) => boolean} predicate
118
+ * @param {string[]} [acc]
119
+ * @returns {string[]}
120
+ */
121
+ function walk(dir, predicate, acc = []) {
122
+ const entries = readdirSync(dir, { withFileTypes: true });
123
+ for (const e of entries) {
124
+ const full = join(dir, e.name);
125
+ if (e.isDirectory()) {
126
+ if (e.name !== 'node_modules' && e.name !== '.git' && e.name !== '__pycache__' && e.name !== '.venv' && e.name !== 'venv') {
127
+ walk(full, predicate, acc);
128
+ }
129
+ } else if (predicate(e.name)) {
130
+ acc.push(e.name);
131
+ }
132
+ }
133
+ return acc;
134
+ }
package/index.js ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GenAT MCP server: tool "GenAT" to generate accessibility tests via n8n.
4
+ * Inputs: url, parentProjectFolder (path to TS or Python Playwright project).
5
+ * Detects framework (language, BDD, Page Object), POSTs to n8n webhook, returns generated files.
6
+ */
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { z } from 'zod';
10
+ import { detectFramework } from './detect-framework.js';
11
+ import { writeGeneratedFiles } from './write-files.js';
12
+
13
+ const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL || 'http://localhost:5678/webhook-test/webhook-genat';
14
+
15
+ const server = new McpServer(
16
+ {
17
+ name: 'GenAT',
18
+ version: '1.0.0',
19
+ },
20
+ {
21
+ capabilities: {
22
+ tools: {},
23
+ },
24
+ }
25
+ );
26
+
27
+ server.registerTool(
28
+ 'GenAT',
29
+ {
30
+ description:
31
+ 'Generate accessibility tests for a URL based on the project folder. Analyzes the page (DOM + a11y), detects project framework (TypeScript/Python, BDD, Page Object), and returns generated test files (feature, spec/step defs, page objects). Optionally write files to the project folder.',
32
+ inputSchema: {
33
+ url: z.string().describe('Page URL to analyze for accessibility (e.g. https://example.com)'),
34
+ parentProjectFolder: z
35
+ .string()
36
+ .describe('Absolute or relative path to the project root containing TypeScript or Python Playwright code'),
37
+ writeToProject: z
38
+ .boolean()
39
+ .optional()
40
+ .describe('If true, write generated files into the project folder (e.g. tests/accessibility/)'),
41
+ },
42
+ },
43
+ async ({ url, parentProjectFolder, writeToProject }) => {
44
+ if (!url || typeof url !== 'string') {
45
+ return {
46
+ content: [{ type: 'text', text: JSON.stringify({ error: 'Missing or invalid "url"' }) }],
47
+ isError: true,
48
+ };
49
+ }
50
+
51
+ let framework;
52
+ try {
53
+ framework = detectFramework(parentProjectFolder || '.');
54
+ } catch (err) {
55
+ return {
56
+ content: [
57
+ {
58
+ type: 'text',
59
+ text: JSON.stringify({
60
+ error: 'Framework detection failed',
61
+ message: err instanceof Error ? err.message : String(err),
62
+ fallback: { scriptType: 'typescript', bddFramework: 'none', pageObject: false },
63
+ }),
64
+ },
65
+ ],
66
+ isError: true,
67
+ };
68
+ }
69
+
70
+ const body = {
71
+ url,
72
+ scriptType: framework.scriptType,
73
+ bddFramework: framework.bddFramework,
74
+ pageObject: framework.pageObject,
75
+ projectSummary: framework.projectSummary,
76
+ };
77
+
78
+ let response;
79
+ try {
80
+ response = await fetch(N8N_WEBHOOK_URL, {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/json' },
83
+ body: JSON.stringify(body),
84
+ });
85
+ } catch (err) {
86
+ return {
87
+ content: [
88
+ {
89
+ type: 'text',
90
+ text: JSON.stringify({
91
+ error: 'Failed to call n8n webhook',
92
+ message: err instanceof Error ? err.message : String(err),
93
+ hint: 'Set N8N_WEBHOOK_URL to your n8n webhook URL (e.g. https://your-n8n/webhook-test/webhook-genat)',
94
+ }),
95
+ },
96
+ ],
97
+ isError: true,
98
+ };
99
+ }
100
+
101
+ if (!response.ok) {
102
+ const text = await response.text();
103
+ return {
104
+ content: [
105
+ {
106
+ type: 'text',
107
+ text: JSON.stringify({
108
+ error: `n8n returned ${response.status}`,
109
+ body: text.slice(0, 1000),
110
+ }),
111
+ },
112
+ ],
113
+ isError: true,
114
+ };
115
+ }
116
+
117
+ let data;
118
+ try {
119
+ data = await response.json();
120
+ } catch {
121
+ return {
122
+ content: [{ type: 'text', text: 'n8n response was not JSON' }],
123
+ isError: true,
124
+ };
125
+ }
126
+
127
+ if (writeToProject && parentProjectFolder && data) {
128
+ try {
129
+ const written = writeGeneratedFiles(parentProjectFolder, data);
130
+ data._written = written;
131
+ } catch (err) {
132
+ data._writeError = err instanceof Error ? err.message : String(err);
133
+ }
134
+ }
135
+
136
+ return {
137
+ content: [
138
+ {
139
+ type: 'text',
140
+ text: JSON.stringify(data, null, 2),
141
+ },
142
+ ],
143
+ };
144
+ }
145
+ );
146
+
147
+ async function main() {
148
+ const transport = new StdioServerTransport();
149
+ await server.connect(transport);
150
+ }
151
+
152
+ main().catch((error) => {
153
+ console.error('GenAT MCP server error:', error);
154
+ process.exit(1);
155
+ });
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "genat-mcp",
3
+ "version": "1.0.0",
4
+ "mcpName": "io.github.asokans@oclc.org/genat",
5
+ "description": "MCP server GenAT: generate accessibility tests via n8n workflow (url + project folder)",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "bin": { "genat-mcp": "index.js" },
9
+ "engines": { "node": ">=20" },
10
+ "files": ["index.js", "detect-framework.js", "write-files.js"],
11
+ "keywords": ["mcp", "accessibility", "playwright", "n8n", "model-context-protocol", "a11y", "testing"],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://git-m1.shr.oclc.org/scm/automation/n8n_playwright_tests.git"
15
+ },
16
+ "homepage": "https://git-m1.shr.oclc.org/scm/automation/n8n_playwright_tests#readme",
17
+ "bugs": { "url": "https://git-m1.shr.oclc.org/scm/automation/n8n_playwright_tests/issues" },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.0.0",
20
+ "zod": "^3.25.0"
21
+ }
22
+ }
package/write-files.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Write generated accessibility test files into the project folder.
3
+ * Uses suggested paths from the workflow response when present.
4
+ */
5
+ import { writeFile, mkdir } from 'fs/promises';
6
+ import { join, resolve } from 'path';
7
+
8
+ const DEFAULT_DIR = 'tests/accessibility';
9
+
10
+ /**
11
+ * @param {string} parentProjectFolder - project root path
12
+ * @param {{ testCode?: string, scriptType?: string, featureFile?: string, stepDefCode?: string, pageObjectCode?: string, suggestedPaths?: Record<string, string> }} data - workflow response
13
+ * @returns {string[]} list of written file paths
14
+ */
15
+ export async function writeGeneratedFiles(parentProjectFolder, data) {
16
+ const root = resolve(parentProjectFolder);
17
+ const baseDir = join(root, DEFAULT_DIR);
18
+ await mkdir(baseDir, { recursive: true });
19
+
20
+ const written = [];
21
+ const scriptType = (data.scriptType || 'typescript').toLowerCase();
22
+
23
+ const defaultNames = {
24
+ typescript: { spec: 'accessibility.spec.ts', steps: 'steps.ts', page: 'AccessibilityPage.ts' },
25
+ python: { spec: 'accessibility_test.py', steps: 'step_definitions.py', page: 'accessibility_page.py' },
26
+ };
27
+ const names = defaultNames[scriptType] || defaultNames.typescript;
28
+ const suggested = data.suggestedPaths || {};
29
+
30
+ if (data.testCode) {
31
+ const relPath = suggested.spec || join(DEFAULT_DIR, names.spec);
32
+ await writeFile(join(root, relPath), data.testCode, 'utf8');
33
+ written.push(relPath);
34
+ }
35
+
36
+ if (data.featureFile) {
37
+ const relPath = suggested.feature || join(DEFAULT_DIR, 'accessibility.feature');
38
+ await writeFile(join(root, relPath), data.featureFile, 'utf8');
39
+ written.push(relPath);
40
+ }
41
+
42
+ if (data.stepDefCode) {
43
+ const relPath = suggested.stepDef || join(DEFAULT_DIR, names.steps);
44
+ await writeFile(join(root, relPath), data.stepDefCode, 'utf8');
45
+ written.push(relPath);
46
+ }
47
+
48
+ if (data.pageObjectCode) {
49
+ const relPath = suggested.pageObject || join(DEFAULT_DIR, names.page);
50
+ await writeFile(join(root, relPath), data.pageObjectCode, 'utf8');
51
+ written.push(relPath);
52
+ }
53
+
54
+ return written;
55
+ }