mcp-interactive-choice 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 +97 -0
- package/bin/native-ui-win32-x64.exe +0 -0
- package/dist/index.js +184 -0
- package/dist/index.test.js +25 -0
- package/dist/spawn.test.js +39 -0
- package/dist/ui +262 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Interactive Choice MCP Server (Native UI)
|
|
2
|
+
|
|
3
|
+
A Model Context Protocol (MCP) server that allows AI agents to ask questions through a **native window** (built with Tauri), preventing context-breaking interruptions and providing a premium user experience.
|
|
4
|
+
|
|
5
|
+
## ✨ Features
|
|
6
|
+
|
|
7
|
+
- **Native Window**: A native window appears when the agent needs your input.
|
|
8
|
+
- **Markdown Support**: Detailed descriptions from the AI are rendered in markdown.
|
|
9
|
+
- **Cross-Platform**: Supports Windows and macOS.
|
|
10
|
+
|
|
11
|
+
## 🚀 Setup & Installation
|
|
12
|
+
|
|
13
|
+
### Option A: Run with npx (Recommended)
|
|
14
|
+
You can use the server directly via `npx` in your MCP client configuration:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"mcpServers": {
|
|
19
|
+
"interactive-choice": {
|
|
20
|
+
"command": "npx",
|
|
21
|
+
"args": [
|
|
22
|
+
"-y",
|
|
23
|
+
"mcp-interactive-choice"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Option B: Local Build
|
|
31
|
+
#### 1. Build Everything
|
|
32
|
+
From the project root:
|
|
33
|
+
```bash
|
|
34
|
+
npm install
|
|
35
|
+
npm run build
|
|
36
|
+
```
|
|
37
|
+
This will:
|
|
38
|
+
1. Build the frontend (Vite)
|
|
39
|
+
2. Build the Tauri binary (Rust)
|
|
40
|
+
3. Compile the MCP server (TypeScript)
|
|
41
|
+
4. Copy the binary to the `bin/` folder
|
|
42
|
+
|
|
43
|
+
#### 2. Register with your MCP Client
|
|
44
|
+
Update your configuration (e.g., `claude_desktop_config.json`):
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"interactive-choice": {
|
|
50
|
+
"command": "node",
|
|
51
|
+
"args": [
|
|
52
|
+
"/path/to/mcp-interactive-choice/dist/index.js"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 🛠️ Tool: `ask_user`
|
|
60
|
+
|
|
61
|
+
The agent calls this tool when it needs a human decision.
|
|
62
|
+
|
|
63
|
+
**Arguments:**
|
|
64
|
+
- `title` (optional): Short headline for the question.
|
|
65
|
+
- `body` (optional): Detailed context in Markdown.
|
|
66
|
+
- `choices` (required): List of strings.
|
|
67
|
+
- `recommended` (optional): One of the strings from `choices` that the agent suggests.
|
|
68
|
+
- `allowCustom` (optional): Boolean to show a text box for custom input.
|
|
69
|
+
- `timeoutSec` (optional): How long to wait (in seconds) before the tool auto-fails.
|
|
70
|
+
|
|
71
|
+
**Response:**
|
|
72
|
+
- Returns the string value of the selected choice.
|
|
73
|
+
- Returns the custom text if provided.
|
|
74
|
+
- Returns `"user cancelled the selection"` if the window is closed manually.
|
|
75
|
+
|
|
76
|
+
## 🛠️ Development & Debugging
|
|
77
|
+
|
|
78
|
+
### UI Development (Hot Reloading)
|
|
79
|
+
To iterate on the UI with hot reloading:
|
|
80
|
+
1. Go to `packages/native-ui`.
|
|
81
|
+
2. Run `npm run tauri dev`.
|
|
82
|
+
|
|
83
|
+
**Running with CLI parameters (Windows/PowerShell):**
|
|
84
|
+
To test specific data during development, use the flag `--input`:
|
|
85
|
+
```powershell
|
|
86
|
+
npm run tauri dev -- -- -- --input '{"title":"Dev Test","choices":["A","B"]}'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 🔍 Testing with MCP Inspector
|
|
90
|
+
1. **Build Everything**: `npm run build` at the root.
|
|
91
|
+
2. **Run Inspector** from the project root:
|
|
92
|
+
```bash
|
|
93
|
+
npx -y @modelcontextprotocol/inspector node dist/index.js
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## 📝 License
|
|
97
|
+
MIT
|
|
Binary file
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
// CLI Arguments for the MCP server itself
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.option("--timeout <number>", "Default timeout in seconds", "60")
|
|
15
|
+
.option("--binary-path <string>", "Path to the native-ui binary")
|
|
16
|
+
.parse(process.argv);
|
|
17
|
+
const options = program.opts();
|
|
18
|
+
const DEFAULT_TIMEOUT = parseInt(options.timeout);
|
|
19
|
+
/**
|
|
20
|
+
* Strategy to find the native-ui binary.
|
|
21
|
+
* 1. Explicit --binary-path flag.
|
|
22
|
+
* 2. Monorepo dev path.
|
|
23
|
+
* 3. npm installation path (bin/native-ui-<platform>-<arch>).
|
|
24
|
+
*/
|
|
25
|
+
function getBinaryPath() {
|
|
26
|
+
if (options.binaryPath)
|
|
27
|
+
return options.binaryPath;
|
|
28
|
+
const platform = process.platform;
|
|
29
|
+
const arch = process.arch;
|
|
30
|
+
const exeSuffix = platform === "win32" ? ".exe" : "";
|
|
31
|
+
// 1. Dev Path (Monorepo)
|
|
32
|
+
// When running from root/dist/index.js, native-ui is in root/packages/native-ui/...
|
|
33
|
+
const devPath = path.resolve(__dirname, `../packages/native-ui/src-tauri/target/release/native-ui${exeSuffix}`);
|
|
34
|
+
// 2. NPM Path (standardized binary name)
|
|
35
|
+
const npmPath = path.resolve(__dirname, `../bin/native-ui-${platform}-${arch}${exeSuffix}`);
|
|
36
|
+
if (fs.existsSync(npmPath))
|
|
37
|
+
return npmPath;
|
|
38
|
+
if (fs.existsSync(devPath))
|
|
39
|
+
return devPath;
|
|
40
|
+
// Fallback to debug path if release doesn't exist
|
|
41
|
+
const debugPath = path.resolve(__dirname, `../packages/native-ui/src-tauri/target/debug/native-ui${exeSuffix}`);
|
|
42
|
+
if (fs.existsSync(debugPath))
|
|
43
|
+
return debugPath;
|
|
44
|
+
return npmPath;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolves the recommended choice string to its index in the choices array.
|
|
48
|
+
* Returns -1 if no recommendation is provided.
|
|
49
|
+
* @throws McpError if the recommendation doesn't match any choice.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveRecommendedIndex(choices, recommended) {
|
|
52
|
+
if (recommended === undefined || recommended === null)
|
|
53
|
+
return -1;
|
|
54
|
+
const target = recommended.trim();
|
|
55
|
+
const index = choices.findIndex(c => c.trim() === target);
|
|
56
|
+
if (index === -1) {
|
|
57
|
+
throw new McpError(ErrorCode.InvalidParams, `recommended choice "${recommended}" does not match any available choices. Available: ${choices.join(", ")}`);
|
|
58
|
+
}
|
|
59
|
+
return index;
|
|
60
|
+
}
|
|
61
|
+
const server = new Server({
|
|
62
|
+
name: "mcp-interactive-choice",
|
|
63
|
+
version: "1.0.0",
|
|
64
|
+
}, {
|
|
65
|
+
capabilities: {
|
|
66
|
+
tools: {},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
70
|
+
return {
|
|
71
|
+
tools: [
|
|
72
|
+
{
|
|
73
|
+
name: "ask_user",
|
|
74
|
+
description: "Ask the user a question with several choices via a native GUI window. Supports Markdown in the body and a recommended choice.",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
title: {
|
|
79
|
+
type: "string",
|
|
80
|
+
description: "(Optional) A concise, high-level summary of the decision required.",
|
|
81
|
+
},
|
|
82
|
+
body: {
|
|
83
|
+
type: "string",
|
|
84
|
+
description: "(Optional) Detailed context or explanation. Supports Markdown (code blocks, lists, etc.) to help the user make an informed choice.",
|
|
85
|
+
},
|
|
86
|
+
choices: {
|
|
87
|
+
type: "array",
|
|
88
|
+
items: { type: "string" },
|
|
89
|
+
description: "(Required) A list of predefined options for the user to select from.",
|
|
90
|
+
},
|
|
91
|
+
recommended: {
|
|
92
|
+
type: "string",
|
|
93
|
+
description: "(Optional) One of the exact strings from the 'choices' array that the agent recommends. The UI will highlight this option.",
|
|
94
|
+
},
|
|
95
|
+
allowCustom: {
|
|
96
|
+
type: "boolean",
|
|
97
|
+
description: "(Optional) Whether to provide a text area for the user to type a custom response not in the choices list. Defaults to false.",
|
|
98
|
+
default: false
|
|
99
|
+
},
|
|
100
|
+
timeoutSec: {
|
|
101
|
+
type: "number",
|
|
102
|
+
description: `(Optional) How long to wait for a user response in seconds. Defaults to ${DEFAULT_TIMEOUT}. If exceeded, the tool returns a timeout error.`,
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
required: ["choices"],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
112
|
+
if (request.params.name === "ask_user") {
|
|
113
|
+
const args = request.params.arguments;
|
|
114
|
+
const choices = args.choices;
|
|
115
|
+
const timeout = (args.timeoutSec || DEFAULT_TIMEOUT) * 1000;
|
|
116
|
+
const recommendedIndex = resolveRecommendedIndex(choices, args.recommended);
|
|
117
|
+
const inputData = {
|
|
118
|
+
title: args.title || "Action Required",
|
|
119
|
+
body: args.body || "",
|
|
120
|
+
choices,
|
|
121
|
+
recommendedIndex,
|
|
122
|
+
allowCustom: !!args.allowCustom,
|
|
123
|
+
};
|
|
124
|
+
const binaryPath = getBinaryPath();
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const child = spawn(binaryPath, ["--input", JSON.stringify(inputData)], {
|
|
127
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
128
|
+
});
|
|
129
|
+
let stdoutData = "";
|
|
130
|
+
child.stdout.on("data", (data) => {
|
|
131
|
+
stdoutData += data.toString();
|
|
132
|
+
});
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
child.kill("SIGKILL");
|
|
135
|
+
resolve({
|
|
136
|
+
content: [{ type: "text", text: "Error: User feedback timed out." }],
|
|
137
|
+
isError: true,
|
|
138
|
+
});
|
|
139
|
+
}, timeout);
|
|
140
|
+
child.on("close", (code) => {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
if (code === 0) {
|
|
143
|
+
try {
|
|
144
|
+
const cleanedStdout = stdoutData.split('\n')
|
|
145
|
+
.filter(line => !line.trim().startsWith('DEBUG'))
|
|
146
|
+
.join('\n')
|
|
147
|
+
.trim();
|
|
148
|
+
const result = JSON.parse(cleanedStdout);
|
|
149
|
+
const textResult = result.custom_input || result.choice || "No selection made";
|
|
150
|
+
resolve({
|
|
151
|
+
content: [{ type: "text", text: textResult }],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
resolve({
|
|
156
|
+
content: [{ type: "text", text: `Error parsing result: ${stdoutData}` }],
|
|
157
|
+
isError: true,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
resolve({
|
|
163
|
+
content: [{ type: "text", text: `Tool window closed unexpectedly (code ${code})` }],
|
|
164
|
+
isError: true,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
child.on("error", (err) => {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
reject(new McpError(ErrorCode.InternalError, `Failed to launch interactive window: ${err.message}`));
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${request.params.name}`);
|
|
175
|
+
});
|
|
176
|
+
async function main() {
|
|
177
|
+
const transport = new StdioServerTransport();
|
|
178
|
+
await server.connect(transport);
|
|
179
|
+
console.error("Interactive Choice MCP Server (Tauri) running on stdio");
|
|
180
|
+
}
|
|
181
|
+
main().catch((error) => {
|
|
182
|
+
console.error("Fatal error in main():", error);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resolveRecommendedIndex } from "./index.js";
|
|
3
|
+
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
describe("resolveRecommendedIndex", () => {
|
|
5
|
+
const choices = ["Apple", "Banana", "Cherry"];
|
|
6
|
+
it("should return -1 if recommended is null or undefined", () => {
|
|
7
|
+
expect(resolveRecommendedIndex(choices, null)).toBe(-1);
|
|
8
|
+
expect(resolveRecommendedIndex(choices, undefined)).toBe(-1);
|
|
9
|
+
});
|
|
10
|
+
it("should return the correct index for a valid recommendation", () => {
|
|
11
|
+
expect(resolveRecommendedIndex(choices, "Banana")).toBe(1);
|
|
12
|
+
});
|
|
13
|
+
it("should handle whitespace correctly", () => {
|
|
14
|
+
expect(resolveRecommendedIndex(choices, " Cherry ")).toBe(2);
|
|
15
|
+
});
|
|
16
|
+
it("should throw McpError for invalid recommendation", () => {
|
|
17
|
+
expect(() => resolveRecommendedIndex(choices, "Dragonfruit")).toThrow(McpError);
|
|
18
|
+
try {
|
|
19
|
+
resolveRecommendedIndex(choices, "Dragonfruit");
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
expect(e.code).toBe(ErrorCode.InvalidParams);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it } from 'vitest';
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
describe('spawn native-ui', () => {
|
|
9
|
+
it('should be able to find and execute the native-ui binary with --help', async () => {
|
|
10
|
+
const platform = process.platform;
|
|
11
|
+
const exeSuffix = platform === 'win32' ? '.exe' : '';
|
|
12
|
+
// Check release first, then debug
|
|
13
|
+
const releasePath = path.resolve(__dirname, `../packages/native-ui/src-tauri/target/release/native-ui${exeSuffix}`);
|
|
14
|
+
const debugPath = path.resolve(__dirname, `../packages/native-ui/src-tauri/target/debug/native-ui${exeSuffix}`);
|
|
15
|
+
const binaryPath = fs.existsSync(releasePath) ? releasePath : debugPath;
|
|
16
|
+
if (!fs.existsSync(binaryPath)) {
|
|
17
|
+
console.warn(`Binary not found at ${binaryPath}, skipping execution test. Run 'npm run build' first.`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const child = spawn(binaryPath, ['--help'], {
|
|
22
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
23
|
+
});
|
|
24
|
+
let stdout = '';
|
|
25
|
+
child.stdout.on('data', (data) => {
|
|
26
|
+
stdout += data.toString();
|
|
27
|
+
});
|
|
28
|
+
child.on('close', (code) => {
|
|
29
|
+
// Tauri apps return 0 for help usually, but some return non-zero if no event loop
|
|
30
|
+
// We just care that it lived long enough to print something or exit cleanly
|
|
31
|
+
console.log('Tauri help output:', stdout);
|
|
32
|
+
resolve(true);
|
|
33
|
+
});
|
|
34
|
+
child.on('error', (err) => {
|
|
35
|
+
reject(err);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}, 10000); // 10s timeout
|
|
39
|
+
});
|
package/dist/ui
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>AI Needs Your Choice</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--primary: #4f46e5;
|
|
13
|
+
--primary-hover: #4338ca;
|
|
14
|
+
--bg-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
|
|
15
|
+
--glass: rgba(255, 255, 255, 0.05);
|
|
16
|
+
--glass-border: rgba(255, 255, 255, 0.1);
|
|
17
|
+
--text-main: #f8fafc;
|
|
18
|
+
--text-dim: #94a3b8;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
* {
|
|
22
|
+
margin: 0;
|
|
23
|
+
padding: 0;
|
|
24
|
+
box-sizing: border-box;
|
|
25
|
+
font-family: 'Outfit', sans-serif;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
body {
|
|
29
|
+
min-height: 100vh;
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
justify-content: center;
|
|
33
|
+
background: var(--bg-gradient);
|
|
34
|
+
background-attachment: fixed;
|
|
35
|
+
color: var(--text-main);
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Abstract blobs for aesthetics */
|
|
40
|
+
.blob {
|
|
41
|
+
position: absolute;
|
|
42
|
+
width: 500px;
|
|
43
|
+
height: 500px;
|
|
44
|
+
background: linear-gradient(180deg, rgba(79, 70, 229, 0.2) 0%, rgba(147, 51, 234, 0.1) 100%);
|
|
45
|
+
filter: blur(80px);
|
|
46
|
+
border-radius: 50%;
|
|
47
|
+
z-index: -1;
|
|
48
|
+
animation: move 20s infinite alternate;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.blob-1 { top: -100px; left: -100px; }
|
|
52
|
+
.blob-2 { bottom: -100px; right: -100px; animation-delay: -5s; }
|
|
53
|
+
|
|
54
|
+
@keyframes move {
|
|
55
|
+
from { transform: translate(0, 0) scale(1); }
|
|
56
|
+
to { transform: translate(50px, 50px) scale(1.1); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.container {
|
|
60
|
+
width: 100%;
|
|
61
|
+
max-width: 500px;
|
|
62
|
+
padding: 2rem;
|
|
63
|
+
background: var(--glass);
|
|
64
|
+
backdrop-filter: blur(12px);
|
|
65
|
+
border: 1px solid var(--glass-border);
|
|
66
|
+
border-radius: 24px;
|
|
67
|
+
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
|
68
|
+
animation: fadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@keyframes fadeIn {
|
|
72
|
+
from { opacity: 0; transform: translateY(20px); }
|
|
73
|
+
to { opacity: 1; transform: translateY(0); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.header {
|
|
77
|
+
margin-bottom: 2rem;
|
|
78
|
+
text-align: center;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.ai-indicator {
|
|
82
|
+
display: inline-flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
gap: 0.5rem;
|
|
85
|
+
background: rgba(79, 70, 229, 0.1);
|
|
86
|
+
padding: 0.5rem 1rem;
|
|
87
|
+
border-radius: 100px;
|
|
88
|
+
color: #818cf8;
|
|
89
|
+
font-size: 0.875rem;
|
|
90
|
+
font-weight: 600;
|
|
91
|
+
margin-bottom: 1rem;
|
|
92
|
+
border: 1px solid rgba(79, 70, 229, 0.2);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.pulse {
|
|
96
|
+
width: 8px;
|
|
97
|
+
height: 8px;
|
|
98
|
+
background: #818cf8;
|
|
99
|
+
border-radius: 50%;
|
|
100
|
+
animation: pulse 2s infinite;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@keyframes pulse {
|
|
104
|
+
0% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 rgba(129, 140, 248, 0.7); }
|
|
105
|
+
70% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 10px rgba(129, 140, 248, 0); }
|
|
106
|
+
100% { transform: scale(1); opacity: 0; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
h1 {
|
|
110
|
+
font-size: 1.5rem;
|
|
111
|
+
font-weight: 600;
|
|
112
|
+
line-height: 1.4;
|
|
113
|
+
color: var(--text-main);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.choices {
|
|
117
|
+
display: flex;
|
|
118
|
+
flex-direction: column;
|
|
119
|
+
gap: 0.75rem;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
button {
|
|
123
|
+
width: 100%;
|
|
124
|
+
padding: 1.25rem;
|
|
125
|
+
background: rgba(255, 255, 255, 0.03);
|
|
126
|
+
border: 1px solid var(--glass-border);
|
|
127
|
+
border-radius: 16px;
|
|
128
|
+
color: var(--text-main);
|
|
129
|
+
font-size: 1rem;
|
|
130
|
+
font-weight: 400;
|
|
131
|
+
text-align: left;
|
|
132
|
+
cursor: pointer;
|
|
133
|
+
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
134
|
+
display: flex;
|
|
135
|
+
justify-content: space-between;
|
|
136
|
+
align-items: center;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
button:hover {
|
|
140
|
+
background: rgba(255, 255, 255, 0.08);
|
|
141
|
+
border-color: rgba(255, 255, 255, 0.2);
|
|
142
|
+
transform: translateX(4px);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
button:active {
|
|
146
|
+
transform: scale(0.98);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
button .arrow {
|
|
150
|
+
opacity: 0;
|
|
151
|
+
transition: 0.2s;
|
|
152
|
+
transform: translateX(-10px);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
button:hover .arrow {
|
|
156
|
+
opacity: 1;
|
|
157
|
+
transform: translateX(0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.footer {
|
|
161
|
+
margin-top: 2rem;
|
|
162
|
+
text-align: center;
|
|
163
|
+
font-size: 0.75rem;
|
|
164
|
+
color: var(--text-dim);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.loading-overlay {
|
|
168
|
+
position: fixed;
|
|
169
|
+
inset: 0;
|
|
170
|
+
background: var(--bg-gradient);
|
|
171
|
+
display: flex;
|
|
172
|
+
align-items: center;
|
|
173
|
+
justify-content: center;
|
|
174
|
+
z-index: 100;
|
|
175
|
+
transition: opacity 0.5s ease;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.loading-overlay.hidden {
|
|
179
|
+
opacity: 0;
|
|
180
|
+
pointer-events: none;
|
|
181
|
+
}
|
|
182
|
+
</style>
|
|
183
|
+
</head>
|
|
184
|
+
<body>
|
|
185
|
+
<div class="blob blob-1"></div>
|
|
186
|
+
<div class="blob blob-2"></div>
|
|
187
|
+
|
|
188
|
+
<div id="loading" class="loading-overlay">
|
|
189
|
+
<div class="ai-indicator">
|
|
190
|
+
<div class="pulse"></div>
|
|
191
|
+
Connecting...
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="container">
|
|
196
|
+
<div class="header">
|
|
197
|
+
<div class="ai-indicator">
|
|
198
|
+
<div class="pulse"></div>
|
|
199
|
+
Agent Interaction
|
|
200
|
+
</div>
|
|
201
|
+
<h1 id="question">Please wait while the question loads...</h1>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div id="choices" class="choices">
|
|
205
|
+
<!-- Buttons will be injected here -->
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<div class="footer">
|
|
209
|
+
Choice will be returned to the AI assistant immediately.
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<script>
|
|
214
|
+
async function init() {
|
|
215
|
+
try {
|
|
216
|
+
const response = await fetch('/api/details');
|
|
217
|
+
const data = await response.json();
|
|
218
|
+
|
|
219
|
+
document.getElementById('question').textContent = data.question;
|
|
220
|
+
const choicesContainer = document.getElementById('choices');
|
|
221
|
+
|
|
222
|
+
data.choices.forEach(choice => {
|
|
223
|
+
const btn = document.createElement('button');
|
|
224
|
+
btn.innerHTML = `${choice} <span class="arrow">→</span>`;
|
|
225
|
+
btn.onclick = () => submit(choice);
|
|
226
|
+
choicesContainer.appendChild(btn);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
document.getElementById('loading').classList.add('hidden');
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.error('Failed to load details', err);
|
|
232
|
+
document.getElementById('question').textContent = "Error: Could not connect to MCP server.";
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function submit(choice) {
|
|
237
|
+
try {
|
|
238
|
+
await fetch('/api/respond', {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { 'Content-Type': 'application/json' },
|
|
241
|
+
body: JSON.stringify({ choice })
|
|
242
|
+
});
|
|
243
|
+
// Close window if possible
|
|
244
|
+
window.close();
|
|
245
|
+
// If window.close() is blocked (standard browser behavior), show success
|
|
246
|
+
document.querySelector('.container').innerHTML = `
|
|
247
|
+
<div style="text-align: center; padding: 2rem;">
|
|
248
|
+
<div class="ai-indicator" style="background: rgba(16, 185, 129, 0.1); color: #10b981; border-color: rgba(16, 185, 129, 0.2);">✓ Response Sent</div>
|
|
249
|
+
<h1 style="margin-top: 1rem;">Done!</h1>
|
|
250
|
+
<p style="color: var(--text-dim); margin-top: 0.5rem;">You can close this tab now.</p>
|
|
251
|
+
</div>
|
|
252
|
+
`;
|
|
253
|
+
} catch (err) {
|
|
254
|
+
console.error('Failed to submit response', err);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
init();
|
|
259
|
+
</script>
|
|
260
|
+
</body>
|
|
261
|
+
</html>
|
|
262
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-interactive-choice",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "MCP server for interactive user questions with native Tauri UI",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-interactive-choice": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"bin",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"workspaces": [
|
|
16
|
+
"packages/*"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build:server": "tsc",
|
|
20
|
+
"build:ui": "npm run build --workspace=native-ui",
|
|
21
|
+
"build": "npm run build:ui && npm run build:server && node scripts/copy-binaries.js",
|
|
22
|
+
"start": "node dist/index.js",
|
|
23
|
+
"test": "vitest run"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.0.3",
|
|
27
|
+
"commander": "^11.1.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^20.11.0",
|
|
31
|
+
"typescript": "^5.3.3",
|
|
32
|
+
"vitest": "^4.0.18"
|
|
33
|
+
}
|
|
34
|
+
}
|