Skip to content

Draft: Basic MCP implementation #4982

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: 3.x
Choose a base branch
from
Open
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
185 changes: 185 additions & 0 deletions bin/codecept-mcp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#!/usr/bin/env node
Copy link
Collaborator

@kobenguyent kobenguyent May 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#!/usr/bin/env node

const { McpServer, ResourceTemplate } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');

// Import specific CodeceptJS modules directly
const Codecept = require('../lib/codecept');
const container = require('../lib/container');
const { getParamsToString } = require('../lib/parser');
const { methodsOfObject } = require('../lib/utils');
const output = require('../lib/output');
const { getConfig, getTestRoot } = require('../lib/command/utils');

// Determine the project path
const projectPath = process.argv.slice(2).find(arg => !arg.includes('mcp.js')) || process.cwd();

/**
 * Initializes CodeceptJS and loads tests.
 * @param {string} customPath - The project path.
 * @returns {object} - The initialized CodeceptJS instance and its configuration.
 */
async function initializeCodeceptJS(customPath) {
  output.print = () => {}; // Disable default output
  const testsPath = getTestRoot(customPath);
  const config = getConfig(customPath);
  const codecept = new Codecept(config, {});
  await codecept.init(testsPath);
  codecept.loadTests();
  return { codecept, config };
}

/**
 * Extracts test information from the loaded CodeceptJS instance.
 * @param {object} codecept - The initialized CodeceptJS instance.
 * @returns {Array<object>} - An array of test objects.
 */
function extractTestInfo(codecept) {
  const mocha = container.mocha();
  mocha.files = codecept.testFiles;
  mocha.loadFiles();

  const tests = [];
  for (const suite of mocha.suite.suites) {
    for (const test of suite.tests) {
      tests.push({
        title: test.title,
        fullTitle: test.fullTitle(),
        body: test.body ? test.body.toString() : '',
        file: suite.file,
        suiteName: suite.title,
        meta: test.meta,
        tags: test.tags,
      });
    }
  }
  return tests;
}

/**
 * Formats test information into a readable text block.
 * @param {Array<object>} tests - An array of test objects.
 * @returns {object} - An MCP content object containing the formatted text.
 */
function formatTestsAsText(tests) {
  const formattedText = tests
    .map(test => [
      `Test: ${test.fullTitle}`,
      `File: ${test.file}`,
      `Suite: ${test.suiteName}`,
      test.tags && test.tags.length ? `Tags: ${test.tags.join(', ')}` : '',
      '',
      'Body:',
      test.body,
      '---',
    ]
      .filter(Boolean)
      .join('\n'))
    .join('\n\n');

  return { content: [{ type: 'text', text: formattedText }] };
}

/**
 * Extracts suite information from the loaded CodeceptJS instance.
 * @param {object} codecept - The initialized CodeceptJS instance.
 * @returns {Array<object>} - An array of suite objects.
 */
function extractSuiteInfo(codecept) {
  const mocha = container.mocha();
  mocha.files = codecept.testFiles;
  mocha.loadFiles();

  const suites = [];
  for (const suite of mocha.suite.suites) {
    suites.push({
      title: suite.title,
      file: suite.file,
      testCount: suite.tests.length,
      tests: suite.tests.map(test => ({
        title: test.title,
        fullTitle: test.fullTitle(),
      })),
    });
  }
  return suites;
}

/**
 * Formats suite information into a readable text block.
 * @param {Array<object>} suites - An array of suite objects.
 * @returns {object} - An MCP content object containing the formatted text.
 */
function formatSuitesAsText(suites) {
  const formattedText = suites
    .map(suite => {
      const testList = suite.tests.map(test => `  - ${test.title}`).join('\n');
      return [`Suite: ${suite.title}`, `File: ${suite.file}`, `Tests (${suite.testCount}):`, testList, '---'].join('\n');
    })
    .join('\n\n');

  return { content: [{ type: 'text', text: formattedText }] };
}

/**
 * Extracts action information from CodeceptJS helpers and support objects.
 * @returns {Array<object>} - An array of action objects.
 */
function extractActionInfo() {
  const helpers = container.helpers();
  const supportI = container.support('I');
  const actions = [];

  // Get actions from helpers
  for (const name in helpers) {
    const helper = helpers[name];
    methodsOfObject(helper).forEach(action => {
      const params = getParamsToString(helper[action]);
      actions.push({
        name: action,
        source: name,
        params,
        type: 'helper',
      });
    });
  }

  // Get actions from I
  for (const name in supportI) {
    if (actions.some(a => a.name === name)) {
      continue;
    }
    const actor = supportI[name];
    const params = getParamsToString(actor);
    actions.push({
      name,
      source: 'I',
      params,
      type: 'support',
    });
  }
  return actions;
}

/**
 * Formats action information into a readable text block.
 * @param {Array<object>} actions - An array of action objects.
 * @returns {object} - An MCP content object containing the formatted text.
 */
function formatActionsAsText(actions) {
  const helperActions = actions
    .filter(a => a.type === 'helper')
    .sort((a, b) => (a.source === b.source ? a.name.localeCompare(b.name) : a.source.localeCompare(b.source)));

  const supportActions = actions.filter(a => a.type === 'support').sort((a, b) => a.name.localeCompare(b.name));

  const formattedText = [
    '# Helper Actions',
    ...helperActions.map(a => `${a.source}.${a.name}(${a.params})`),
    '',
    '# Support Actions',
    ...supportActions.map(a => `I.${a.name}(${a.params})`),
  ].join('\n');

  return { content: [{ type: 'text', text: formattedText }] };
}

/**
 * Starts the MCP Server.
 */
async function startServer() {
  const { codecept } = await initializeCodeceptJS(projectPath);

  const server = new McpServer({
    name: 'CodeceptJS',
    version: '1.0.0',
    url: 'https://codecept.io',
    description: 'CodeceptJS Model Context Protocol Server',
  });

  server.tool('list-tests', {}, async () => {
    const tests = extractTestInfo(codecept);
    return formatTestsAsText(tests);
  });

  server.tool('list-suites', {}, async () => {
    const suites = extractSuiteInfo(codecept);
    return formatSuitesAsText(suites);
  });

  server.tool('list-actions', {}, async () => {
    const actions = extractActionInfo();
    return formatActionsAsText(actions);
  });

  const transport = new StdioServerTransport();
  await server.connect(transport);
}

// Start the server
startServer().catch(err => {
  console.error('Error starting MCP server:', err);
  process.exit(1);
});


const { McpServer, ResourceTemplate } = require('@modelcontextprotocol/sdk/server/mcp.js')
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js')
const path = require('path')
const fs = require('fs')

// Import core CodeceptJS modules
const Codecept = require('../lib/codecept')
const container = require('../lib/container')
const { getParamsToString } = require('../lib/parser')
const { methodsOfObject } = require('../lib/utils')
const output = require('../lib/output')
const { getConfig, getTestRoot } = require('../lib/command/utils')

// Simple path handling - use argument if provided, otherwise use current directory

const lastArg = process.argv[process.argv.length - 1]

const customPath = lastArg.includes('mcp.js') ? process.cwd() : lastArg

/**
* Start MCP Server
*/
async function startServer() {
// Disable default output
output.print = () => {}

// Initialize CodeceptJS
const testsPath = getTestRoot(customPath)
const config = getConfig(customPath)
const codecept = new Codecept(config, {})

codecept.init(testsPath)
codecept.loadTests()

// Setup MCP server
const server = new McpServer({
name: 'CodeceptJS',
version: '1.0.0',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we get the current version of codeceptjs?

url: 'https://codecept.io',
description: 'CodeceptJS Model Context Protocol Server',
})

// Convert Resource: tests
server.tool('list-tests', {}, async () => {
// Use the same approach as dryRun.js to collect test information
const mocha = container.mocha()
mocha.files = codecept.testFiles
mocha.loadFiles()

const tests = []

// Iterate through all suites and tests
for (const suite of mocha.suite.suites) {
for (const test of suite.tests) {
tests.push({
title: test.title,
fullTitle: test.fullTitle(),
body: test.body ? test.body.toString() : '',
file: suite.file,
suiteName: suite.title,
meta: test.meta,
tags: test.tags,
})
}
}

// Format each test as a readable text block
const formattedText = tests
.map(test => {
return [`Test: ${test.fullTitle}`, `File: ${test.file}`, `Suite: ${test.suiteName}`, test.tags && test.tags.length ? `Tags: ${test.tags?.join(', ')}` : '', '', 'Body:', test.body, '---']
.filter(Boolean)
.join('\n')
})
.join('\n\n')

return {
content: [{ type: 'text', text: formattedText }],
}
})

// Convert Resource: suites
server.tool('list-suites', {}, async () => {
// Use the same approach as dryRun.js to collect suite information
const mocha = container.mocha()
mocha.files = codecept.testFiles
mocha.loadFiles()

const suites = []

// Iterate through all suites
for (const suite of mocha.suite.suites) {
suites.push({
title: suite.title,
file: suite.file,
testCount: suite.tests.length,
tests: suite.tests.map(test => ({
title: test.title,
fullTitle: test.fullTitle(),
})),
})
}

// Format each suite as a readable text block
const formattedText = suites
.map(suite => {
const testList = suite.tests.map(test => ` - ${test.title}`).join('\n')
return [`Suite: ${suite.title}`, `File: ${suite.file}`, `Tests (${suite.testCount}):`, testList, '---'].join('\n')
})
.join('\n\n')

return {
content: [{ type: 'text', text: formattedText }],
}
})

// // Convert Resource: actions
// server.tool("list-actions",
// { },
// async () => {
// const helpers = container.helpers();
// const supportI = container.support('I');
// const actions = [];

// // Get actions from helpers
// for (const name in helpers) {
// const helper = helpers[name];
// methodsOfObject(helper).forEach(action => {
// const params = getParamsToString(helper[action]);
// actions.push({
// name: action,
// source: name,
// params,
// type: 'helper'
// });
// });
// }

// // Get actions from I
// for (const name in supportI) {
// if (actions.some(a => a.name === name)) {
// continue;
// }
// const actor = supportI[name];
// const params = getParamsToString(actor);
// actions.push({
// name,
// source: 'I',
// params,
// type: 'support'
// });
// }

// // Format actions as a readable text list
// const helperActions = actions.filter(a => a.type === 'helper')
// .sort((a, b) => a.source === b.source ? a.name.localeCompare(b.name) : a.source.localeCompare(b.source));

// const supportActions = actions.filter(a => a.type === 'support')
// .sort((a, b) => a.name.localeCompare(b.name));

// // Create formatted text output
// const formattedText = [
// '# Helper Actions',
// ...helperActions.map(a => `${a.source}.${a.name}(${a.params})`),
// '',
// '# Support Actions',
// ...supportActions.map(a => `I.${a.name}(${a.params})`)
// ].join('\n');

// return {
// content: [{ type: "text", text: formattedText }]
// };
// }
// );
// Start MCP server using stdio transport
const transport = new StdioServerTransport()
await server.connect(transport)
}

// Start the server without nested error handling
startServer().catch(err => {
console.error(err)
process.exit(1)
})
39 changes: 39 additions & 0 deletions docs/MCP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# CodeceptJS Model Context Protocol (MCP) Integration

CodeceptJS supports the Model Context Protocol (MCP) for IDE integration, particularly with Cursor.

## Using with Cursor

To use CodeceptJS with Cursor:

1. Start the MCP server directly using the standalone executable:

```bash
npx codecept-mcp
```

or

```bash
node bin/codecept-mcp.js [path]
```

Where `[path]` is optional and points to your CodeceptJS project (defaults to current directory).

2. In Cursor, connect to the MCP server by selecting "Connect to MCP Server" and choose "CodeceptJS" from the list.

## Available Tools

The server provides these tools:

- `list-tests`: List all availble tests
Copy link
Preview

Copilot AI Apr 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo detected in the word 'availble'; please update it to 'available'.

Suggested change
- `list-tests`: List all availble tests
- `list-tests`: List all available tests

Copilot uses AI. Check for mistakes.

- `list-suites`: List all suites

## Troubleshooting

If Cursor shows "Found 0 tools, 0 resources, and 0 resource templates" when connecting:

1. Make sure you're using the standalone executable (`codecept-mcp.js`)
2. The MCP server process should be running independently and not as a subprocess
3. Check that you're in the correct CodeceptJS project directory by specifying path as a parameter
4. Verify that your CodeceptJS configuration file (codecept.conf.js or codecept.json) exists in path and is valid
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
},
"types": "typings/index.d.ts",
"bin": {
"codeceptjs": "./bin/codecept.js"
"codeceptjs": "./bin/codecept.js",
"codecept-mcp": "./bin/codecept-mcp.js"
},
"repository": "Codeception/codeceptjs",
"scripts": {
Expand Down Expand Up @@ -80,6 +81,7 @@
"@cucumber/cucumber-expressions": "18",
"@cucumber/gherkin": "32.1.0",
"@cucumber/messages": "27.2.0",
"@modelcontextprotocol/sdk": "^1.10.2",
"@xmldom/xmldom": "0.9.8",
"acorn": "8.14.1",
"arrify": "3.0.0",
Expand All @@ -95,8 +97,8 @@
"figures": "3.2.0",
"fn-args": "4.0.0",
"fs-extra": "11.3.0",
"glob": ">=9.0.0 <12",
"fuse.js": "^7.0.0",
"glob": ">=9.0.0 <12",
"html-minifier-terser": "7.2.0",
"inquirer": "8.2.6",
"invisi-data": "^1.0.0",
Expand All @@ -114,7 +116,8 @@
"promise-retry": "1.1.1",
"resq": "1.11.0",
"sprintf-js": "1.1.3",
"uuid": "11.1.0"
"uuid": "11.1.0",
"zod": "^3.24.3"
},
"optionalDependencies": {
"@codeceptjs/detox-helper": "1.1.8"
Expand Down
Loading