@rawwee/interactive-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.
@@ -0,0 +1,117 @@
1
+ import { z } from 'zod';
2
+ // Define capability conforming to ToolCapabilityInfo
3
+ const capabilityInfo = {
4
+ description: 'Ask the user a question in an OpenTUI terminal prompt and await their reply.',
5
+ parameters: {
6
+ type: 'object',
7
+ properties: {
8
+ projectName: {
9
+ type: 'string',
10
+ description: 'Identifies the context/project making the request (used in prompt formatting)',
11
+ },
12
+ message: {
13
+ type: 'string',
14
+ description: 'The specific question for the user (appears in the prompt)',
15
+ },
16
+ predefinedOptions: {
17
+ type: 'array',
18
+ items: { type: 'string' },
19
+ optional: true, // Mark as optional here too for consistency
20
+ description: 'Predefined options for the user to choose from (optional)',
21
+ },
22
+ baseDirectory: {
23
+ type: 'string',
24
+ description: 'Required absolute path to the current repository root (must be a git repo root; used as file autocomplete/search scope)',
25
+ },
26
+ },
27
+ required: ['projectName', 'message', 'baseDirectory'],
28
+ },
29
+ };
30
+ // Define description conforming to ToolRegistrationDescription
31
+ const registrationDescription = (globalTimeoutSeconds) => `<description>
32
+ Send a question to the user via the OpenTUI terminal prompt. **Crucial for clarifying requirements, confirming plans, or resolving ambiguity.**
33
+ You should call this tool whenever it has **any** uncertainty or needs clarification or confirmation, even for trivial or silly questions.
34
+ Feel free to ask anything! **Proactive questioning is preferred over making assumptions.**
35
+ </description>
36
+
37
+ <importantNotes>
38
+ - (!important!) **Use this tool FREQUENTLY** for any question that requires user input or confirmation.
39
+ - (!important!) Continue to generate existing messages after user answers.
40
+ - (!important!) Provide predefined options for quick selection if applicable.
41
+ - (!important!) **Essential for validating assumptions before proceeding with significant actions (e.g., code edits, running commands).**
42
+ </importantNotes>
43
+
44
+ <whenToUseThisTool>
45
+ - When you need clarification on user requirements or preferences
46
+ - When multiple implementation approaches are possible and user input is needed
47
+ - **Before making potentially impactful changes (code edits, file operations, complex commands)**
48
+ - When you need to confirm assumptions before proceeding
49
+ - When you need additional information not available in the current context
50
+ - When validating potential solutions before implementation
51
+ - When facing ambiguous instructions that require clarification
52
+ - When seeking feedback on generated code or solutions
53
+ - When needing permission to modify critical files or functionality
54
+ - **Whenever you feel even slightly unsure about the user's intent or the correct next step.**
55
+ </whenToUseThisTool>
56
+
57
+ <features>
58
+ - OpenTUI prompt with markdown rendering (including code/diff blocks)
59
+ - Supports option mode + free-text input mode when predefinedOptions are provided
60
+ - Returns user response or timeout notification (timeout defaults to ${globalTimeoutSeconds} seconds)
61
+ - Maintains context across user interactions
62
+ - Handles empty responses gracefully
63
+ - Properly formats prompt with project context
64
+ - baseDirectory is required, must be the current repository root, and controls file autocomplete/search scope explicitly
65
+ </features>
66
+
67
+ <bestPractices>
68
+ - Keep questions concise and specific
69
+ - Provide clear options when applicable
70
+ - Do not ask the question if you have another tool that can answer the question
71
+ - e.g. when you searching file in the current repository, do not ask the question "Do you want to search for a file in the current repository?"
72
+ - e.g. prefer to use other tools to find the answer (Cursor tools or other MCP Server tools)
73
+ - Limit questions to only what's necessary **to resolve the uncertainty**
74
+ - Format complex questions into simple choices
75
+ - Reference specific code or files when relevant
76
+ - Indicate why the information is needed
77
+ - Use appropriate urgency based on importance
78
+ </bestPractices>
79
+
80
+ <parameters>
81
+ - projectName: Identifies the context/project making the request (used in prompt formatting)
82
+ - message: The specific question for the user (appears in the prompt)
83
+ - predefinedOptions: Predefined options for the user to choose from (optional)
84
+ - baseDirectory: Required absolute path to the current repository root (must be a git repo root)
85
+ </parameters>
86
+
87
+ <examples>
88
+ - "Should I implement the authentication using JWT or OAuth?"
89
+ - "Do you want to use TypeScript interfaces or type aliases for this component?"
90
+ - "I found three potential bugs. Should I fix them all or focus on the critical one first?"
91
+ - "Can I refactor the database connection code to use connection pooling?"
92
+ - "Is it acceptable to add React Router as a dependency?"
93
+ - "I plan to modify function X in file Y. Is that correct?"
94
+ - { "projectName": "web-app", "message": "Which file should I edit?", "baseDirectory": "/workspace/web-app" }
95
+ </examples>`;
96
+ // Define the Zod schema (as a raw shape object)
97
+ const rawSchema = {
98
+ projectName: z
99
+ .string()
100
+ .describe('Identifies the context/project making the request (used in prompt formatting)'),
101
+ message: z
102
+ .string()
103
+ .describe('The specific question for the user (appears in the prompt)'),
104
+ predefinedOptions: z
105
+ .array(z.string())
106
+ .optional()
107
+ .describe('Predefined options for the user to choose from (optional)'),
108
+ baseDirectory: z
109
+ .string()
110
+ .describe('Required absolute path to the current repository root (must be a git repo root; used as file autocomplete/search scope)'),
111
+ };
112
+ // Combine into a single ToolDefinition object
113
+ export const requestUserInputTool = {
114
+ capability: capabilityInfo,
115
+ description: registrationDescription,
116
+ schema: rawSchema, // Use the raw shape here
117
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ /**
4
+ * Validate that the provided base directory is an absolute path pointing to a
5
+ * git repository root (contains a `.git` entry).
6
+ */
7
+ export async function validateRepositoryBaseDirectory(baseDirectory) {
8
+ if (baseDirectory.trim().length === 0) {
9
+ throw new Error('Invalid baseDirectory: value cannot be empty. Provide an absolute path to the current repository root.');
10
+ }
11
+ if (!path.isAbsolute(baseDirectory)) {
12
+ throw new Error('Invalid baseDirectory: path must be absolute and point to the current repository root.');
13
+ }
14
+ const normalizedPath = path.resolve(baseDirectory);
15
+ let baseDirectoryStats;
16
+ try {
17
+ baseDirectoryStats = await fs.stat(normalizedPath);
18
+ }
19
+ catch (error) {
20
+ if (error &&
21
+ typeof error === 'object' &&
22
+ 'code' in error &&
23
+ error.code === 'ENOENT') {
24
+ throw new Error(`Invalid baseDirectory: "${normalizedPath}" does not exist. Provide an absolute path to the current repository root.`);
25
+ }
26
+ throw new Error(`Invalid baseDirectory: unable to access "${normalizedPath}". Provide an absolute path to the current repository root.`);
27
+ }
28
+ if (!baseDirectoryStats.isDirectory()) {
29
+ throw new Error(`Invalid baseDirectory: "${normalizedPath}" is not a directory. Provide an absolute path to the current repository root.`);
30
+ }
31
+ try {
32
+ await fs.access(path.join(normalizedPath, '.git'));
33
+ }
34
+ catch (error) {
35
+ if (error &&
36
+ typeof error === 'object' &&
37
+ 'code' in error &&
38
+ error.code === 'ENOENT') {
39
+ throw new Error(`Invalid baseDirectory: "${normalizedPath}" is not a git repository root (missing ".git").`);
40
+ }
41
+ throw new Error(`Invalid baseDirectory: unable to verify ".git" in "${normalizedPath}".`);
42
+ }
43
+ return normalizedPath;
44
+ }
@@ -0,0 +1,67 @@
1
+ import os from 'os';
2
+ import { spawn } from 'child_process';
3
+ function runCommand(command, args, input) {
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn(command, args, {
6
+ stdio: ['pipe', 'pipe', 'pipe'],
7
+ });
8
+ let stdout = '';
9
+ let stderr = '';
10
+ child.stdout.on('data', (chunk) => {
11
+ stdout += chunk.toString();
12
+ });
13
+ child.stderr.on('data', (chunk) => {
14
+ stderr += chunk.toString();
15
+ });
16
+ child.on('error', (error) => {
17
+ reject(error);
18
+ });
19
+ child.on('close', (code) => {
20
+ if (code === 0) {
21
+ resolve({ stdout });
22
+ return;
23
+ }
24
+ reject(new Error(`Command "${command} ${args.join(' ')}" failed with code ${code}: ${stderr || 'no stderr output'}`));
25
+ });
26
+ if (typeof input === 'string') {
27
+ child.stdin.write(input);
28
+ }
29
+ child.stdin.end();
30
+ });
31
+ }
32
+ export async function copyTextToClipboard(text) {
33
+ const platform = os.platform();
34
+ if (platform === 'darwin') {
35
+ await runCommand('pbcopy', [], text);
36
+ return;
37
+ }
38
+ if (platform === 'linux') {
39
+ await runCommand('xclip', ['-selection', 'clipboard'], text);
40
+ return;
41
+ }
42
+ if (platform === 'win32') {
43
+ await runCommand('clip', [], text);
44
+ return;
45
+ }
46
+ throw new Error(`Clipboard copy is not supported on platform: ${platform}`);
47
+ }
48
+ export async function readTextFromClipboard() {
49
+ const platform = os.platform();
50
+ if (platform === 'darwin') {
51
+ const result = await runCommand('pbpaste', []);
52
+ return result.stdout;
53
+ }
54
+ if (platform === 'linux') {
55
+ const result = await runCommand('xclip', ['-selection', 'clipboard', '-o']);
56
+ return result.stdout;
57
+ }
58
+ if (platform === 'win32') {
59
+ const result = await runCommand('powershell', [
60
+ '-NoProfile',
61
+ '-Command',
62
+ 'Get-Clipboard -Raw',
63
+ ]);
64
+ return result.stdout;
65
+ }
66
+ throw new Error(`Clipboard paste is not supported on platform: ${platform}`);
67
+ }
@@ -0,0 +1,65 @@
1
+ import { pino, } from 'pino';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ const logDir = path.resolve(os.tmpdir(), 'interactive-mcp-logs');
6
+ const logFile = path.join(logDir, 'dev.log');
7
+ // Ensure log directory exists
8
+ if (process.env.NODE_ENV === 'development' && !fs.existsSync(logDir)) {
9
+ try {
10
+ fs.mkdirSync(logDir, { recursive: true });
11
+ }
12
+ catch (error) {
13
+ console.error('Failed to create log directory:', error); // Use console here as logger isn't initialized yet
14
+ // Consider fallback behavior or exiting if logging is critical
15
+ }
16
+ }
17
+ const isDevelopment = process.env.NODE_ENV === 'development';
18
+ const loggerOptions = {
19
+ level: isDevelopment ? 'trace' : 'silent', // Default level
20
+ };
21
+ if (isDevelopment) {
22
+ let devTransportConfig;
23
+ try {
24
+ // Attempt to open the file in append mode to check writability before setting up transport
25
+ const fd = fs.openSync(logFile, 'a');
26
+ fs.closeSync(fd);
27
+ devTransportConfig = {
28
+ targets: [
29
+ {
30
+ target: 'pino-pretty', // Log to console with pretty printing
31
+ options: {
32
+ colorize: true,
33
+ sync: false, // Use async logging
34
+ translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
35
+ ignore: 'pid,hostname',
36
+ },
37
+ level: 'trace', // Log all levels to the console in dev
38
+ },
39
+ {
40
+ target: 'pino/file', // Log to file
41
+ options: { destination: logFile, mkdir: true }, // Specify file path and ensure directory exists
42
+ level: 'trace', // Log all levels to the file in dev
43
+ },
44
+ ],
45
+ };
46
+ }
47
+ catch (error) {
48
+ console.error(`Failed to setup file transport for ${logFile}. Falling back to console-only logging. Error:`, error);
49
+ // Fallback transport to console only using pino-pretty if file access fails
50
+ devTransportConfig = {
51
+ target: 'pino-pretty',
52
+ options: {
53
+ colorize: true,
54
+ sync: false,
55
+ translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
56
+ ignore: 'pid,hostname',
57
+ },
58
+ level: 'trace',
59
+ };
60
+ }
61
+ // Add transport to logger options only in development
62
+ loggerOptions.transport = devTransportConfig;
63
+ }
64
+ const logger = pino(loggerOptions);
65
+ export default logger;
@@ -0,0 +1,85 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ export const SEARCH_ROOT_ENV_KEY = 'INTERACTIVE_MCP_SEARCH_ROOT';
4
+ const normalizeAbsolutePath = (value) => {
5
+ if (!value) {
6
+ return null;
7
+ }
8
+ const trimmed = value.trim();
9
+ if (!trimmed || !path.isAbsolute(trimmed)) {
10
+ return null;
11
+ }
12
+ return path.resolve(trimmed);
13
+ };
14
+ const existsAsDirectory = async (targetPath) => {
15
+ try {
16
+ const stats = await fs.stat(targetPath);
17
+ return stats.isDirectory();
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ };
23
+ const isGitRepositoryRoot = async (targetPath) => {
24
+ if (!(await existsAsDirectory(targetPath))) {
25
+ return false;
26
+ }
27
+ try {
28
+ await fs.access(path.join(targetPath, '.git'));
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ };
35
+ const findRepositoryRootFrom = async (startPath) => {
36
+ let current = path.resolve(startPath);
37
+ while (true) {
38
+ if (await isGitRepositoryRoot(current)) {
39
+ return current;
40
+ }
41
+ const parent = path.dirname(current);
42
+ if (parent === current) {
43
+ return null;
44
+ }
45
+ current = parent;
46
+ }
47
+ };
48
+ const toDirectoryPath = async (targetPath) => {
49
+ try {
50
+ const stats = await fs.stat(targetPath);
51
+ return stats.isDirectory() ? targetPath : path.dirname(targetPath);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ };
57
+ const findRepositoryRootFromScriptEntry = async (argvEntry) => {
58
+ if (!argvEntry) {
59
+ return null;
60
+ }
61
+ const resolvedArgvEntry = path.isAbsolute(argvEntry)
62
+ ? path.resolve(argvEntry)
63
+ : path.resolve(process.cwd(), argvEntry);
64
+ const startDirectory = await toDirectoryPath(resolvedArgvEntry);
65
+ if (!startDirectory) {
66
+ return null;
67
+ }
68
+ return findRepositoryRootFrom(startDirectory);
69
+ };
70
+ export async function resolveSearchRoot(preferredSearchRoot, runtimeHints = {}) {
71
+ const preferredCandidate = normalizeAbsolutePath(preferredSearchRoot);
72
+ if (preferredCandidate && (await isGitRepositoryRoot(preferredCandidate))) {
73
+ return preferredCandidate;
74
+ }
75
+ const envCandidate = normalizeAbsolutePath(process.env[SEARCH_ROOT_ENV_KEY]);
76
+ if (envCandidate && (await isGitRepositoryRoot(envCandidate))) {
77
+ return envCandidate;
78
+ }
79
+ const argvEntryRoot = await findRepositoryRootFromScriptEntry(runtimeHints.argvEntry ?? process.argv[1]);
80
+ if (argvEntryRoot) {
81
+ return argvEntryRoot;
82
+ }
83
+ const cwdRoot = await findRepositoryRootFrom(runtimeHints.cwd ?? process.cwd());
84
+ return cwdRoot ?? undefined;
85
+ }
@@ -0,0 +1,101 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import logger from './logger.js';
5
+ function resolveRuntimeExecutable() {
6
+ if (process.versions.bun) {
7
+ return process.execPath;
8
+ }
9
+ return process.env.INTERACTIVE_MCP_BUN_PATH || 'bun';
10
+ }
11
+ function createEscapedRuntimeCommand(executable, scriptPath, args, env) {
12
+ const runtimeArgs = [scriptPath, ...args].map((arg) => `"${arg}"`).join(' ');
13
+ const envPrefix = createShellEnvPrefix(env);
14
+ return `${envPrefix}exec "${executable}" ${runtimeArgs}; exit 0`
15
+ .replace(/\\/g, '\\\\')
16
+ .replace(/"/g, '\\"');
17
+ }
18
+ function createLauncherScript(executable, scriptPath, args, env) {
19
+ const runtimeArgs = [scriptPath, ...args].map((arg) => `"${arg}"`).join(' ');
20
+ const envPrefix = createShellEnvPrefix(env);
21
+ return `#!/bin/bash\n${envPrefix}exec "${executable}" ${runtimeArgs}\n`;
22
+ }
23
+ function escapeForDoubleQuotedShellString(value) {
24
+ return value
25
+ .replace(/\\/g, '\\\\')
26
+ .replace(/"/g, '\\"')
27
+ .replace(/\$/g, '\\$')
28
+ .replace(/`/g, '\\`');
29
+ }
30
+ function createShellEnvPrefix(env) {
31
+ if (!env) {
32
+ return '';
33
+ }
34
+ return Object.entries(env)
35
+ .filter(([key, value]) => typeof value === 'string' &&
36
+ key.length > 0 &&
37
+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
38
+ .map(([key, value]) => {
39
+ const escapedValue = escapeForDoubleQuotedShellString(value);
40
+ return `export ${key}="${escapedValue}"; `;
41
+ })
42
+ .join('');
43
+ }
44
+ export function spawnDetachedTerminal(options) {
45
+ const platform = os.platform();
46
+ const runtimeExecutable = resolveRuntimeExecutable();
47
+ const spawnEnv = {
48
+ ...process.env,
49
+ ...options.env,
50
+ };
51
+ if (platform === 'darwin') {
52
+ const darwinArgs = options.darwinArgs ?? options.args;
53
+ const escapedRuntimeCommand = createEscapedRuntimeCommand(runtimeExecutable, options.scriptPath, darwinArgs, options.env);
54
+ const command = `osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "${escapedRuntimeCommand}"'`;
55
+ const childProcess = spawn(command, [], {
56
+ stdio: ['ignore', 'ignore', 'ignore'],
57
+ shell: true,
58
+ detached: true,
59
+ env: spawnEnv,
60
+ });
61
+ const launchViaOpenCommand = async () => {
62
+ try {
63
+ await fs.writeFile(options.macLauncherPath, createLauncherScript(runtimeExecutable, options.scriptPath, darwinArgs, options.env), 'utf8');
64
+ await fs.chmod(options.macLauncherPath, 0o755);
65
+ const openProc = spawn('open', ['-a', 'Terminal', options.macLauncherPath], {
66
+ stdio: ['ignore', 'ignore', 'ignore'],
67
+ detached: true,
68
+ env: spawnEnv,
69
+ });
70
+ openProc.unref();
71
+ }
72
+ catch (error) {
73
+ logger.error({ error }, options.macFallbackLogMessage);
74
+ }
75
+ };
76
+ childProcess.on('error', () => {
77
+ void launchViaOpenCommand();
78
+ });
79
+ childProcess.on('close', (code) => {
80
+ if (code !== null && code !== 0) {
81
+ void launchViaOpenCommand();
82
+ }
83
+ });
84
+ return childProcess;
85
+ }
86
+ if (platform === 'win32') {
87
+ return spawn(runtimeExecutable, [options.scriptPath, ...options.args], {
88
+ stdio: ['ignore', 'ignore', 'ignore'],
89
+ shell: true,
90
+ detached: true,
91
+ windowsHide: false,
92
+ env: spawnEnv,
93
+ });
94
+ }
95
+ return spawn(runtimeExecutable, [options.scriptPath, ...options.args], {
96
+ stdio: ['ignore', 'ignore', 'ignore'],
97
+ shell: true,
98
+ detached: true,
99
+ env: spawnEnv,
100
+ });
101
+ }
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@rawwee/interactive-mcp",
3
+ "version": "1.0.0",
4
+ "main": "dist/index.js",
5
+ "type": "module",
6
+ "bin": {
7
+ "interactive-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE",
13
+ "package.json"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc --outDir dist && tsc-alias",
17
+ "start": "bun dist/index.js",
18
+ "lint": "eslint \"src/**/*.{js,ts,jsx,tsx}\"",
19
+ "format": "prettier --write \"src/**/*.{js,ts,jsx,tsx,json,md}\"",
20
+ "check-types": "tsc --noEmit",
21
+ "prepare": "husky"
22
+ },
23
+ "keywords": [],
24
+ "author": "",
25
+ "license": "MIT",
26
+ "description": "",
27
+ "devDependencies": {
28
+ "@eslint/js": "^9.25.1",
29
+ "@semantic-release/commit-analyzer": "^13.0.1",
30
+ "@semantic-release/git": "^10.0.1",
31
+ "@semantic-release/github": "^11.0.2",
32
+ "@semantic-release/npm": "^13.1.5",
33
+ "@semantic-release/release-notes-generator": "^14.0.3",
34
+ "@types/bun": "^1.3.10",
35
+ "@types/node": "^22.15.2",
36
+ "@types/node-notifier": "^8.0.5",
37
+ "@types/pino": "^7.0.5",
38
+ "@types/react": "^19.1.2",
39
+ "conventional-changelog-conventionalcommits": "^8.0.0",
40
+ "eslint": "^9.25.1",
41
+ "eslint-config-prettier": "^10.1.2",
42
+ "eslint-plugin-prettier": "^5.2.6",
43
+ "globals": "^16.0.0",
44
+ "husky": "^9.1.7",
45
+ "jiti": "^2.4.2",
46
+ "lint-staged": "^15.5.1",
47
+ "pino-pretty": "^13.0.0",
48
+ "prettier": "^3.5.3",
49
+ "semantic-release": "^24.2.3",
50
+ "tsc-alias": "^1.8.15",
51
+ "typescript": "^5.8.3",
52
+ "typescript-eslint": "^8.31.0"
53
+ },
54
+ "dependencies": {
55
+ "@modelcontextprotocol/sdk": "^1.10.2",
56
+ "@opentui/core": "^0.1.86",
57
+ "@opentui/react": "^0.1.86",
58
+ "@types/yargs": "^17.0.33",
59
+ "node-notifier": "^10.0.1",
60
+ "pino": "^9.6.0",
61
+ "react": "^19.2.0",
62
+ "yargs": "^17.7.2",
63
+ "zod": "^3.24.3"
64
+ },
65
+ "lint-staged": {
66
+ "*.{js,ts,jsx,tsx}": [
67
+ "eslint --fix"
68
+ ],
69
+ "*.{js,ts,jsx,tsx,json,md}": [
70
+ "prettier --write"
71
+ ]
72
+ },
73
+ "packageManager": "bun@1.3.10"
74
+ }