electron-dev-bridge 0.1.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/LICENSE +21 -0
- package/README.md +555 -0
- package/dist/cdp-tools/dom-query.d.ts +2 -0
- package/dist/cdp-tools/dom-query.js +307 -0
- package/dist/cdp-tools/helpers.d.ts +17 -0
- package/dist/cdp-tools/helpers.js +48 -0
- package/dist/cdp-tools/index.d.ts +5 -0
- package/dist/cdp-tools/index.js +26 -0
- package/dist/cdp-tools/interaction.d.ts +2 -0
- package/dist/cdp-tools/interaction.js +195 -0
- package/dist/cdp-tools/lifecycle.d.ts +2 -0
- package/dist/cdp-tools/lifecycle.js +78 -0
- package/dist/cdp-tools/navigation.d.ts +2 -0
- package/dist/cdp-tools/navigation.js +128 -0
- package/dist/cdp-tools/state.d.ts +2 -0
- package/dist/cdp-tools/state.js +109 -0
- package/dist/cdp-tools/types.d.ts +22 -0
- package/dist/cdp-tools/types.js +1 -0
- package/dist/cdp-tools/visual.d.ts +2 -0
- package/dist/cdp-tools/visual.js +130 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +50 -0
- package/dist/cli/init.d.ts +1 -0
- package/dist/cli/init.js +146 -0
- package/dist/cli/register.d.ts +1 -0
- package/dist/cli/register.js +40 -0
- package/dist/cli/serve.d.ts +1 -0
- package/dist/cli/serve.js +34 -0
- package/dist/cli/validate.d.ts +1 -0
- package/dist/cli/validate.js +65 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +3 -0
- package/dist/scanner/ipc-scanner.d.ts +6 -0
- package/dist/scanner/ipc-scanner.js +14 -0
- package/dist/scanner/schema-scanner.d.ts +6 -0
- package/dist/scanner/schema-scanner.js +14 -0
- package/dist/server/cdp-bridge.d.ts +12 -0
- package/dist/server/cdp-bridge.js +72 -0
- package/dist/server/mcp-server.d.ts +2 -0
- package/dist/server/mcp-server.js +126 -0
- package/dist/server/resource-builder.d.ts +9 -0
- package/dist/server/resource-builder.js +15 -0
- package/dist/server/tool-builder.d.ts +9 -0
- package/dist/server/tool-builder.js +39 -0
- package/dist/utils/load-config.d.ts +5 -0
- package/dist/utils/load-config.js +17 -0
- package/package.json +75 -0
- package/skills/electron-app-dev/SKILL.md +140 -0
- package/skills/electron-debugging/SKILL.md +203 -0
- package/skills/electron-e2e-testing/SKILL.md +181 -0
package/dist/cli/init.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { resolve, relative, dirname, basename } from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { scanForHandlers } from '../scanner/ipc-scanner.js';
|
|
4
|
+
import { scanForSchemas } from '../scanner/schema-scanner.js';
|
|
5
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'out']);
|
|
6
|
+
function findSourceFiles(dir) {
|
|
7
|
+
const results = [];
|
|
8
|
+
for (const entry of readdirSync(dir)) {
|
|
9
|
+
if (SKIP_DIRS.has(entry))
|
|
10
|
+
continue;
|
|
11
|
+
const full = resolve(dir, entry);
|
|
12
|
+
const stat = statSync(full);
|
|
13
|
+
if (stat.isDirectory()) {
|
|
14
|
+
results.push(...findSourceFiles(full));
|
|
15
|
+
}
|
|
16
|
+
else if (/\.(ts|js|mts|mjs)$/.test(entry) && !entry.endsWith('.d.ts')) {
|
|
17
|
+
results.push(full);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
function singularize(word) {
|
|
23
|
+
if (word.endsWith('s') && !word.endsWith('ss')) {
|
|
24
|
+
return word.slice(0, -1);
|
|
25
|
+
}
|
|
26
|
+
return word;
|
|
27
|
+
}
|
|
28
|
+
function deriveDescription(channel) {
|
|
29
|
+
const parts = channel.split(':');
|
|
30
|
+
if (parts.length === 2) {
|
|
31
|
+
const domain = parts[0].toLowerCase();
|
|
32
|
+
const action = parts[1];
|
|
33
|
+
const capitalAction = action.charAt(0).toUpperCase() + action.slice(1);
|
|
34
|
+
return `${capitalAction} ${domain}`;
|
|
35
|
+
}
|
|
36
|
+
return channel;
|
|
37
|
+
}
|
|
38
|
+
function matchSchemaToChannel(channel, schemas, configDir) {
|
|
39
|
+
const parts = channel.split(':');
|
|
40
|
+
if (parts.length !== 2)
|
|
41
|
+
return undefined;
|
|
42
|
+
const domain = singularize(parts[0].toLowerCase());
|
|
43
|
+
const action = parts[1].toLowerCase();
|
|
44
|
+
for (const schema of schemas) {
|
|
45
|
+
const schemaLower = schema.name.toLowerCase();
|
|
46
|
+
if (schemaLower.includes(domain) && schemaLower.includes(action)) {
|
|
47
|
+
const rel = relative(configDir, schema.file)
|
|
48
|
+
.replace(/\.(ts|js|mts|mjs)$/, '');
|
|
49
|
+
const importPath = rel.startsWith('.') ? rel : `./${rel}`;
|
|
50
|
+
return { schema, importPath };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
function buildToolEntries(handlers, schemas, configDir) {
|
|
56
|
+
const imports = new Map();
|
|
57
|
+
const toolEntries = [];
|
|
58
|
+
let linkedCount = 0;
|
|
59
|
+
for (const handler of handlers) {
|
|
60
|
+
const match = matchSchemaToChannel(handler.channel, schemas, configDir);
|
|
61
|
+
const description = deriveDescription(handler.channel);
|
|
62
|
+
if (match) {
|
|
63
|
+
linkedCount++;
|
|
64
|
+
if (!imports.has(match.importPath)) {
|
|
65
|
+
imports.set(match.importPath, new Set());
|
|
66
|
+
}
|
|
67
|
+
imports.get(match.importPath).add(match.schema.name);
|
|
68
|
+
toolEntries.push(` '${handler.channel}': {\n` +
|
|
69
|
+
` description: '${description}',\n` +
|
|
70
|
+
` schema: ${match.schema.name},\n` +
|
|
71
|
+
` }`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
toolEntries.push(` '${handler.channel}': {\n` +
|
|
75
|
+
` description: '${description}',\n` +
|
|
76
|
+
` }`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { toolEntries, imports, linkedCount };
|
|
80
|
+
}
|
|
81
|
+
function generateConfigSource(appName, toolEntries, imports) {
|
|
82
|
+
const importLines = [
|
|
83
|
+
`import { defineConfig } from 'electron-dev-bridge'`,
|
|
84
|
+
];
|
|
85
|
+
for (const [path, names] of imports) {
|
|
86
|
+
const sorted = [...names].sort();
|
|
87
|
+
importLines.push(`import { ${sorted.join(', ')} } from '${path}'`);
|
|
88
|
+
}
|
|
89
|
+
const toolsBlock = toolEntries.length > 0
|
|
90
|
+
? toolEntries.join(',\n')
|
|
91
|
+
: ` // No IPC handlers detected. Add tools manually:\n // 'channel:action': { description: 'Description' }`;
|
|
92
|
+
return `${importLines.join('\n')}
|
|
93
|
+
|
|
94
|
+
export default defineConfig({
|
|
95
|
+
app: {
|
|
96
|
+
name: '${appName}',
|
|
97
|
+
},
|
|
98
|
+
tools: {
|
|
99
|
+
${toolsBlock}
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
104
|
+
export async function init() {
|
|
105
|
+
const configPath = resolve('electron-mcp.config.ts');
|
|
106
|
+
if (existsSync(configPath)) {
|
|
107
|
+
console.error('electron-mcp.config.ts already exists. Delete it to re-initialize.');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
const cwd = process.cwd();
|
|
111
|
+
console.log('Scanning source files...');
|
|
112
|
+
const sourceFiles = findSourceFiles(cwd);
|
|
113
|
+
console.log(` Found ${sourceFiles.length} source files`);
|
|
114
|
+
const allHandlers = [];
|
|
115
|
+
for (const file of sourceFiles) {
|
|
116
|
+
allHandlers.push(...scanForHandlers(file));
|
|
117
|
+
}
|
|
118
|
+
console.log(` Found ${allHandlers.length} ipcMain.handle() calls`);
|
|
119
|
+
const allSchemas = [];
|
|
120
|
+
for (const file of sourceFiles) {
|
|
121
|
+
allSchemas.push(...scanForSchemas(file));
|
|
122
|
+
}
|
|
123
|
+
console.log(` Found ${allSchemas.length} Zod schema exports`);
|
|
124
|
+
let appName = basename(cwd);
|
|
125
|
+
const pkgPath = resolve(cwd, 'package.json');
|
|
126
|
+
if (existsSync(pkgPath)) {
|
|
127
|
+
try {
|
|
128
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
129
|
+
if (pkg.name)
|
|
130
|
+
appName = pkg.name;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// ignore parse errors
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const { toolEntries, imports, linkedCount } = buildToolEntries(allHandlers, allSchemas, dirname(configPath));
|
|
137
|
+
console.log(` Linked ${linkedCount} schemas to handlers`);
|
|
138
|
+
const config = generateConfigSource(appName, toolEntries, imports);
|
|
139
|
+
writeFileSync(configPath, config, 'utf-8');
|
|
140
|
+
console.log(`\nGenerated electron-mcp.config.ts`);
|
|
141
|
+
console.log(` ${allHandlers.length} tools, ${linkedCount} with schemas`);
|
|
142
|
+
console.log(`\nNext steps:`);
|
|
143
|
+
console.log(` 1. Review and edit electron-mcp.config.ts`);
|
|
144
|
+
console.log(` 2. Run: npx electron-mcp validate`);
|
|
145
|
+
console.log(` 3. Run: npx electron-mcp register`);
|
|
146
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function register(): Promise<void>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
const CONFIG_NAMES = [
|
|
5
|
+
'electron-mcp.config.ts',
|
|
6
|
+
'electron-mcp.config.js',
|
|
7
|
+
'electron-mcp.config.mjs',
|
|
8
|
+
];
|
|
9
|
+
export async function register() {
|
|
10
|
+
let configPath;
|
|
11
|
+
for (const name of CONFIG_NAMES) {
|
|
12
|
+
const candidate = resolve(name);
|
|
13
|
+
if (existsSync(candidate)) {
|
|
14
|
+
configPath = candidate;
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (!configPath) {
|
|
19
|
+
console.error('Error: No electron-mcp config found. Run: npx electron-mcp init');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const configContent = readFileSync(configPath, 'utf-8');
|
|
23
|
+
const nameMatch = configContent.match(/name:\s*['"]([^'"]+)['"]/);
|
|
24
|
+
const appName = nameMatch?.[1] || 'electron-app';
|
|
25
|
+
console.log('Registering with Claude Code...');
|
|
26
|
+
console.log(` Server name: ${appName}`);
|
|
27
|
+
console.log(` Working directory: ${process.cwd()}`);
|
|
28
|
+
try {
|
|
29
|
+
execFileSync('claude', [
|
|
30
|
+
'mcp', 'add', '--scope', 'user',
|
|
31
|
+
appName, '--', 'npx', 'electron-mcp', 'serve'
|
|
32
|
+
], { stdio: 'inherit' });
|
|
33
|
+
console.log('\nRegistered! Run: claude mcp list to verify.');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
console.error('\nRegistration failed. Is Claude Code CLI installed?');
|
|
37
|
+
console.error(` Manual: claude mcp add --scope user "${appName}" -- npx electron-mcp serve`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function serve(configPath?: string): Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { startServer } from '../server/mcp-server.js';
|
|
4
|
+
import { loadConfig } from '../utils/load-config.js';
|
|
5
|
+
const CONFIG_NAMES = [
|
|
6
|
+
'electron-mcp.config.ts',
|
|
7
|
+
'electron-mcp.config.js',
|
|
8
|
+
'electron-mcp.config.mjs',
|
|
9
|
+
];
|
|
10
|
+
export async function serve(configPath) {
|
|
11
|
+
let resolvedPath;
|
|
12
|
+
if (configPath) {
|
|
13
|
+
resolvedPath = resolve(configPath);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
for (const name of CONFIG_NAMES) {
|
|
17
|
+
const candidate = resolve(name);
|
|
18
|
+
if (existsSync(candidate)) {
|
|
19
|
+
resolvedPath = candidate;
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (!resolvedPath || !existsSync(resolvedPath)) {
|
|
25
|
+
console.error('Error: No config file found. Create electron-mcp.config.ts or run: npx electron-mcp init');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const config = await loadConfig(resolvedPath);
|
|
29
|
+
if (!config || !config.app || !config.tools) {
|
|
30
|
+
console.error('Error: Invalid config. Must export default defineConfig({ app, tools })');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
await startServer(config);
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function validate(): Promise<void>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { loadConfig } from '../utils/load-config.js';
|
|
4
|
+
const CONFIG_NAMES = [
|
|
5
|
+
'electron-mcp.config.ts',
|
|
6
|
+
'electron-mcp.config.js',
|
|
7
|
+
'electron-mcp.config.mjs',
|
|
8
|
+
];
|
|
9
|
+
export async function validate() {
|
|
10
|
+
let configPath;
|
|
11
|
+
for (const name of CONFIG_NAMES) {
|
|
12
|
+
const candidate = resolve(name);
|
|
13
|
+
if (existsSync(candidate)) {
|
|
14
|
+
configPath = candidate;
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (!configPath) {
|
|
19
|
+
console.error('Config file: not found');
|
|
20
|
+
console.error(' Run: npx electron-mcp init');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
console.log(`Config file: ${configPath.split('/').pop()}`);
|
|
24
|
+
let config;
|
|
25
|
+
try {
|
|
26
|
+
config = await loadConfig(configPath);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
console.error(`Config load failed: ${err.message}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
if (!config.app?.name) {
|
|
33
|
+
console.error('app.name is required');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const toolCount = Object.keys(config.tools || {}).length;
|
|
37
|
+
const schemasCount = Object.values(config.tools || {}).filter(t => t.schema).length;
|
|
38
|
+
console.log(`${toolCount} IPC tools defined, ${schemasCount} with Zod schemas`);
|
|
39
|
+
const resourceCount = Object.keys(config.resources || {}).length;
|
|
40
|
+
if (resourceCount > 0) {
|
|
41
|
+
console.log(`${resourceCount} resources defined`);
|
|
42
|
+
}
|
|
43
|
+
let cdpCount = 0;
|
|
44
|
+
if (config.cdpTools) {
|
|
45
|
+
const { getCdpTools } = await import('../cdp-tools/index.js');
|
|
46
|
+
const { CdpBridge } = await import('../server/cdp-bridge.js');
|
|
47
|
+
const dummyBridge = new CdpBridge();
|
|
48
|
+
let allCdpTools = getCdpTools(dummyBridge, config.app, config.screenshots);
|
|
49
|
+
if (Array.isArray(config.cdpTools)) {
|
|
50
|
+
const allowed = new Set(config.cdpTools);
|
|
51
|
+
allCdpTools = allCdpTools.filter(t => allowed.has(t.definition.name));
|
|
52
|
+
}
|
|
53
|
+
cdpCount = allCdpTools.length;
|
|
54
|
+
console.log(`CDP tools: enabled (${cdpCount} tools)`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.log('CDP tools: disabled');
|
|
58
|
+
}
|
|
59
|
+
for (const [channel, tool] of Object.entries(config.tools || {})) {
|
|
60
|
+
if (tool.preloadPath) {
|
|
61
|
+
console.log(`Note: Tool '${channel}' has preloadPath override: ${tool.preloadPath}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
console.log(`\nTotal: ${toolCount + cdpCount} MCP tools ready`);
|
|
65
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ZodType } from 'zod';
|
|
2
|
+
export interface ToolConfig {
|
|
3
|
+
description: string;
|
|
4
|
+
schema?: ZodType<any>;
|
|
5
|
+
preloadPath?: string;
|
|
6
|
+
returns?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ResourceConfig {
|
|
9
|
+
description: string;
|
|
10
|
+
uri: string;
|
|
11
|
+
pollExpression: string;
|
|
12
|
+
}
|
|
13
|
+
export interface AppConfig {
|
|
14
|
+
name: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
debugPort?: number;
|
|
17
|
+
electronBin?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ScreenshotConfig {
|
|
20
|
+
dir?: string;
|
|
21
|
+
format?: 'png' | 'jpeg';
|
|
22
|
+
}
|
|
23
|
+
export interface ElectronMcpConfig {
|
|
24
|
+
app: AppConfig;
|
|
25
|
+
tools: Record<string, ToolConfig>;
|
|
26
|
+
resources?: Record<string, ResourceConfig>;
|
|
27
|
+
cdpTools?: boolean | string[];
|
|
28
|
+
screenshots?: ScreenshotConfig;
|
|
29
|
+
}
|
|
30
|
+
export declare function defineConfig(config: ElectronMcpConfig): ElectronMcpConfig;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
export function scanForHandlers(filePath) {
|
|
3
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
4
|
+
const handlers = [];
|
|
5
|
+
const regex = /ipcMain\.handle\(\s*['"]([^'"]+)['"]/g;
|
|
6
|
+
let match;
|
|
7
|
+
while ((match = regex.exec(content)) !== null) {
|
|
8
|
+
const channel = match[1];
|
|
9
|
+
const upToMatch = content.slice(0, match.index);
|
|
10
|
+
const line = upToMatch.split('\n').length;
|
|
11
|
+
handlers.push({ channel, line, file: filePath });
|
|
12
|
+
}
|
|
13
|
+
return handlers;
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
export function scanForSchemas(filePath) {
|
|
3
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
4
|
+
const schemas = [];
|
|
5
|
+
const regex = /export\s+const\s+(\w+Schema)\s*=\s*z\./g;
|
|
6
|
+
let match;
|
|
7
|
+
while ((match = regex.exec(content)) !== null) {
|
|
8
|
+
const name = match[1];
|
|
9
|
+
const upToMatch = content.slice(0, match.index);
|
|
10
|
+
const line = upToMatch.split('\n').length;
|
|
11
|
+
schemas.push({ name, line, file: filePath });
|
|
12
|
+
}
|
|
13
|
+
return schemas;
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class CdpBridge {
|
|
2
|
+
private client;
|
|
3
|
+
private port;
|
|
4
|
+
constructor(port?: number);
|
|
5
|
+
get connected(): boolean;
|
|
6
|
+
setPort(port: number): void;
|
|
7
|
+
connect(maxRetries?: number): Promise<void>;
|
|
8
|
+
ensureConnected(): void;
|
|
9
|
+
evaluate(expression: string, awaitPromise?: boolean): Promise<any>;
|
|
10
|
+
getRawClient(): any;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import CDP from 'chrome-remote-interface';
|
|
2
|
+
export class CdpBridge {
|
|
3
|
+
client = null;
|
|
4
|
+
port;
|
|
5
|
+
constructor(port = 9229) {
|
|
6
|
+
this.port = port;
|
|
7
|
+
}
|
|
8
|
+
get connected() {
|
|
9
|
+
return this.client !== null;
|
|
10
|
+
}
|
|
11
|
+
setPort(port) {
|
|
12
|
+
this.port = port;
|
|
13
|
+
}
|
|
14
|
+
async connect(maxRetries = 10) {
|
|
15
|
+
let lastError;
|
|
16
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
17
|
+
try {
|
|
18
|
+
const targets = await CDP.List({ port: this.port });
|
|
19
|
+
const page = targets.find((t) => t.type === 'page');
|
|
20
|
+
if (!page)
|
|
21
|
+
throw new Error('No page target found among CDP targets');
|
|
22
|
+
this.client = await CDP({ target: page, port: this.port });
|
|
23
|
+
await this.client.Runtime.enable();
|
|
24
|
+
await this.client.DOM.enable();
|
|
25
|
+
await this.client.Page.enable();
|
|
26
|
+
await this.client.Network.enable();
|
|
27
|
+
this.client.on('disconnect', () => { this.client = null; });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
lastError = err;
|
|
32
|
+
if (attempt < maxRetries) {
|
|
33
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`Cannot connect to Electron app on port ${this.port} after ${maxRetries} attempts. ` +
|
|
38
|
+
`Is the app running with --remote-debugging-port=${this.port}? ` +
|
|
39
|
+
`(${lastError?.message})`);
|
|
40
|
+
}
|
|
41
|
+
ensureConnected() {
|
|
42
|
+
if (!this.client) {
|
|
43
|
+
throw new Error('Not connected to an Electron app. ' +
|
|
44
|
+
'Start the app with --remote-debugging-port and use the connect tool first.');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async evaluate(expression, awaitPromise = true) {
|
|
48
|
+
this.ensureConnected();
|
|
49
|
+
const { result, exceptionDetails } = await this.client.Runtime.evaluate({
|
|
50
|
+
expression,
|
|
51
|
+
returnByValue: true,
|
|
52
|
+
awaitPromise,
|
|
53
|
+
});
|
|
54
|
+
if (exceptionDetails) {
|
|
55
|
+
const errText = exceptionDetails.exception?.description ||
|
|
56
|
+
exceptionDetails.text ||
|
|
57
|
+
'Unknown evaluation error';
|
|
58
|
+
throw new Error(`JS evaluation error: ${errText}`);
|
|
59
|
+
}
|
|
60
|
+
return result.value;
|
|
61
|
+
}
|
|
62
|
+
getRawClient() {
|
|
63
|
+
this.ensureConnected();
|
|
64
|
+
return this.client;
|
|
65
|
+
}
|
|
66
|
+
async close() {
|
|
67
|
+
if (this.client) {
|
|
68
|
+
await this.client.close();
|
|
69
|
+
this.client = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { CdpBridge } from './cdp-bridge.js';
|
|
5
|
+
import { buildTools } from './tool-builder.js';
|
|
6
|
+
import { buildResources } from './resource-builder.js';
|
|
7
|
+
import { getCdpTools } from '../cdp-tools/index.js';
|
|
8
|
+
function registerToolHandlers(server, bridge, ipcTools, cdpToolDefs) {
|
|
9
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
10
|
+
tools: [
|
|
11
|
+
...ipcTools.map(t => ({
|
|
12
|
+
name: t.name,
|
|
13
|
+
description: t.description,
|
|
14
|
+
inputSchema: t.inputSchema,
|
|
15
|
+
})),
|
|
16
|
+
...cdpToolDefs.map(t => t.definition),
|
|
17
|
+
],
|
|
18
|
+
}));
|
|
19
|
+
const ipcHandlerMap = new Map();
|
|
20
|
+
for (const tool of ipcTools) {
|
|
21
|
+
ipcHandlerMap.set(tool.name, tool);
|
|
22
|
+
}
|
|
23
|
+
const cdpHandlerMap = new Map();
|
|
24
|
+
for (const tool of cdpToolDefs) {
|
|
25
|
+
cdpHandlerMap.set(tool.definition.name, tool.handler);
|
|
26
|
+
}
|
|
27
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
28
|
+
const { name, arguments: args } = request.params;
|
|
29
|
+
const ipcTool = ipcHandlerMap.get(name);
|
|
30
|
+
if (ipcTool) {
|
|
31
|
+
try {
|
|
32
|
+
const argsJson = args && Object.keys(args).length > 0
|
|
33
|
+
? JSON.stringify(args)
|
|
34
|
+
: '';
|
|
35
|
+
const expression = argsJson
|
|
36
|
+
? `${ipcTool.preloadPath}(${argsJson})`
|
|
37
|
+
: `${ipcTool.preloadPath}()`;
|
|
38
|
+
const result = await bridge.evaluate(expression, true);
|
|
39
|
+
return {
|
|
40
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
46
|
+
isError: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const cdpHandler = cdpHandlerMap.get(name);
|
|
51
|
+
if (cdpHandler) {
|
|
52
|
+
try {
|
|
53
|
+
return await cdpHandler(args || {});
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
return {
|
|
57
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
58
|
+
isError: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
64
|
+
isError: true,
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function registerResourceHandlers(server, bridge, resources) {
|
|
69
|
+
if (resources.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
72
|
+
resources: resources.map(r => ({
|
|
73
|
+
uri: r.uri,
|
|
74
|
+
name: r.name,
|
|
75
|
+
description: r.description,
|
|
76
|
+
mimeType: r.mimeType,
|
|
77
|
+
})),
|
|
78
|
+
}));
|
|
79
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
80
|
+
const resource = resources.find(r => r.uri === request.params.uri);
|
|
81
|
+
if (!resource) {
|
|
82
|
+
throw new Error(`Unknown resource: ${request.params.uri}`);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const data = await bridge.evaluate(resource.pollExpression, true);
|
|
86
|
+
return {
|
|
87
|
+
contents: [{
|
|
88
|
+
uri: resource.uri,
|
|
89
|
+
mimeType: resource.mimeType,
|
|
90
|
+
text: JSON.stringify(data, null, 2),
|
|
91
|
+
}],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
throw new Error(`Failed to read resource ${resource.uri}: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
export async function startServer(config) {
|
|
100
|
+
const bridge = new CdpBridge(config.app.debugPort || 9229);
|
|
101
|
+
const ipcTools = await buildTools(config);
|
|
102
|
+
let cdpToolDefs = [];
|
|
103
|
+
if (config.cdpTools) {
|
|
104
|
+
cdpToolDefs = getCdpTools(bridge, config.app, config.screenshots);
|
|
105
|
+
if (Array.isArray(config.cdpTools)) {
|
|
106
|
+
const allowed = new Set(config.cdpTools);
|
|
107
|
+
cdpToolDefs = cdpToolDefs.filter(t => allowed.has(t.definition.name));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const resources = buildResources(config);
|
|
111
|
+
const server = new Server({ name: config.app.name, version: '0.1.0' }, { capabilities: {
|
|
112
|
+
tools: {},
|
|
113
|
+
...(resources.length > 0 ? { resources: {} } : {}),
|
|
114
|
+
} });
|
|
115
|
+
registerToolHandlers(server, bridge, ipcTools, cdpToolDefs);
|
|
116
|
+
registerResourceHandlers(server, bridge, resources);
|
|
117
|
+
const cleanup = async () => {
|
|
118
|
+
await bridge.close();
|
|
119
|
+
await server.close();
|
|
120
|
+
process.exit(0);
|
|
121
|
+
};
|
|
122
|
+
process.on('SIGINT', cleanup);
|
|
123
|
+
process.on('SIGTERM', cleanup);
|
|
124
|
+
const transport = new StdioServerTransport();
|
|
125
|
+
await server.connect(transport);
|
|
126
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ElectronMcpConfig } from '../index.js';
|
|
2
|
+
export interface ResolvedResource {
|
|
3
|
+
uri: string;
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
pollExpression: string;
|
|
7
|
+
mimeType: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function buildResources(config: ElectronMcpConfig): ResolvedResource[];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function buildResources(config) {
|
|
2
|
+
if (!config.resources)
|
|
3
|
+
return [];
|
|
4
|
+
const resources = [];
|
|
5
|
+
for (const [channel, resourceConfig] of Object.entries(config.resources)) {
|
|
6
|
+
resources.push({
|
|
7
|
+
uri: resourceConfig.uri,
|
|
8
|
+
name: channel,
|
|
9
|
+
description: resourceConfig.description,
|
|
10
|
+
pollExpression: resourceConfig.pollExpression,
|
|
11
|
+
mimeType: 'application/json',
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return resources;
|
|
15
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ElectronMcpConfig } from '../index.js';
|
|
2
|
+
export interface ResolvedTool {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
inputSchema: Record<string, any>;
|
|
6
|
+
channel: string;
|
|
7
|
+
preloadPath: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function buildTools(config: ElectronMcpConfig): Promise<ResolvedTool[]>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
function channelToToolName(channel) {
|
|
2
|
+
return channel.replace(/:/g, '_');
|
|
3
|
+
}
|
|
4
|
+
function channelToPreloadPath(channel) {
|
|
5
|
+
const [domain, action] = channel.split(':');
|
|
6
|
+
return `window.electronAPI.${domain}.${action}`;
|
|
7
|
+
}
|
|
8
|
+
async function zodToJsonSchema(schema) {
|
|
9
|
+
try {
|
|
10
|
+
const { zodToJsonSchema: convert } = await import('zod-to-json-schema');
|
|
11
|
+
const jsonSchema = convert(schema, { target: 'openApi3' });
|
|
12
|
+
const { $schema, ...rest } = jsonSchema;
|
|
13
|
+
return rest;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { type: 'object' };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function buildTools(config) {
|
|
20
|
+
const tools = [];
|
|
21
|
+
for (const [channel, toolConfig] of Object.entries(config.tools)) {
|
|
22
|
+
let inputSchema = { type: 'object' };
|
|
23
|
+
if (toolConfig.schema) {
|
|
24
|
+
inputSchema = await zodToJsonSchema(toolConfig.schema);
|
|
25
|
+
}
|
|
26
|
+
let description = toolConfig.description;
|
|
27
|
+
if (toolConfig.returns) {
|
|
28
|
+
description += ` Returns: ${toolConfig.returns}`;
|
|
29
|
+
}
|
|
30
|
+
tools.push({
|
|
31
|
+
name: channelToToolName(channel),
|
|
32
|
+
description,
|
|
33
|
+
inputSchema,
|
|
34
|
+
channel,
|
|
35
|
+
preloadPath: toolConfig.preloadPath || channelToPreloadPath(channel),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return tools;
|
|
39
|
+
}
|