feat: add mcp server, reorganize repo

This commit is contained in:
2026-09-03 17:20:08 +05:00
parent c1dda4d257
commit 1fd8ad6b50
48 changed files with 1691 additions and 12467 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rubber Duck - Voice Activity Detector</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🦆</text></svg>" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="container">
<div id="duck-wrapper">
<div id="duck-indicator" class="silent">
<svg viewBox="0 0 100 100" class="duck-icon">
<path d="M35 60 Q20 55 15 45 Q10 35 20 30 Q25 28 30 30 Q28 20 35 15 Q42 10 50 15 Q55 12 60 18 Q65 15 68 22 Q75 25 78 32 Q85 35 82 45 Q80 50 75 52 Q80 58 75 65 Q70 72 60 70 Q55 75 45 75 Q40 72 35 70 Z" fill="#FFD700" stroke="#DAA520" stroke-width="2"/>
<circle cx="45" cy="38" r="5" fill="#333"/>
<path d="M38 28 Q42 25 46 28" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"/>
<ellipse cx="68" cy="45" rx="10" ry="6" fill="#FF8C00" opacity="0.6"/>
</svg>
<div id="glow-ring"></div>
</div>
</div>
<p id="status-text">Click "Start" to begin</p>
<button id="toggle-btn" class="btn">Start</button>
<p id="error-text" class="error hidden"></p>
<p class="hint">Grant microphone access to start voice detection</p>
</div>
<script type="module" src="/main.js"></script>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
const indicator = document.getElementById('duck-indicator')
const statusText = document.getElementById('status-text')
const toggleBtn = document.getElementById('toggle-btn')
const errorText = document.getElementById('error-text')
let detector = null
class SmartVoiceDetector {
constructor(options = {}) {
this.threshold = options.threshold ?? -50
this.silenceDelay = options.silenceDelay ?? 700
this.onSpeechStart = options.onSpeechStart || (() => {})
this.onSpeechEnd = options.onSpeechEnd || (() => {})
this.isSpeaking = false
this.timeout = null
this.audioContext = null
this.running = false
}
async start() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
this.audioContext = new AudioContext()
const source = this.audioContext.createMediaStreamSource(stream)
const analyser = this.audioContext.createAnalyser()
analyser.fftSize = 1024
analyser.smoothingTimeConstant = 0.4
source.connect(analyser)
const bufferLength = analyser.frequencyBinCount
const dataArray = new Float32Array(bufferLength)
const sampleRate = this.audioContext.sampleRate
const minHz = 85
const maxHz = 3000
const minIndex = Math.floor(minHz / (sampleRate / analyser.fftSize))
const maxIndex = Math.ceil(maxHz / (sampleRate / analyser.fftSize))
this.running = true
const check = () => {
if (!this.running) return
analyser.getFloatFrequencyData(dataArray)
let maxVolume = -Infinity
for (let i = minIndex; i <= maxIndex; i++) {
if (dataArray[i] > maxVolume) maxVolume = dataArray[i]
}
if (maxVolume > this.threshold) {
if (!this.isSpeaking) {
this.isSpeaking = true
this.onSpeechStart()
}
clearTimeout(this.timeout)
this.timeout = null
} else if (this.isSpeaking && !this.timeout) {
this.timeout = setTimeout(() => {
this.isSpeaking = false
this.onSpeechEnd()
}, this.silenceDelay)
}
requestAnimationFrame(check)
}
check()
}
stop() {
this.running = false
if (this.audioContext) {
this.audioContext.close()
this.audioContext = null
}
}
}
toggleBtn.addEventListener('click', async () => {
if (detector) {
detector.stop()
detector = null
statusText.textContent = 'Stopped'
toggleBtn.textContent = 'Start'
indicator.classList.remove('speaking')
indicator.classList.add('silent')
return
}
toggleBtn.disabled = true
statusText.textContent = 'Starting...'
try {
detector = new SmartVoiceDetector({
threshold: -50,
onSpeechStart: () => {
indicator.classList.remove('silent')
indicator.classList.add('speaking')
statusText.textContent = 'Voice detected'
},
onSpeechEnd: () => {
indicator.classList.remove('speaking')
indicator.classList.add('silent')
statusText.textContent = 'Listening...'
},
})
await detector.start()
statusText.textContent = 'Listening...'
toggleBtn.textContent = 'Stop'
toggleBtn.disabled = false
} catch (err) {
console.error('Mic error:', err)
errorText.textContent = err.message || String(err)
errorText.classList.remove('hidden')
statusText.textContent = 'Error'
toggleBtn.textContent = 'Retry'
toggleBtn.disabled = false
}
})
+33
View File
@@ -0,0 +1,33 @@
{
"name": "rubber-duck",
"version": "0.0.1",
"description": "Best debug tool - rubber duck",
"keywords": [
"rubber",
"duck",
"debug",
"tool",
"fun"
],
"homepage": "https://github.com/Ku6epXBOCTuK/rubber-duck#readme",
"bugs": {
"url": "https://github.com/Ku6epXBOCTuK/rubber-duck/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Ku6epXBOCTuK/rubber-duck.git"
},
"license": "MIT",
"author": "Ku6epXBOCTuK",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "vite",
"test": "echo \"Error: no test specified\" && exit 1",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^8.0.11"
}
}
+107
View File
@@ -0,0 +1,107 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #fff;
}
.container {
text-align: center;
}
#duck-wrapper {
position: relative;
display: inline-block;
}
#duck-indicator {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 160px;
height: 160px;
transition: filter 0.3s ease;
}
.duck-icon {
width: 120px;
height: 120px;
position: relative;
z-index: 2;
}
#glow-ring {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
transition: all 0.3s ease;
z-index: 1;
pointer-events: none;
}
#duck-indicator.silent #glow-ring {
background: radial-gradient(circle, rgba(255, 50, 50, 0.3) 0%, transparent 70%);
box-shadow: 0 0 40px 10px rgba(255, 50, 50, 0.2), inset 0 0 40px 10px rgba(255, 50, 50, 0.05);
}
#duck-indicator.speaking #glow-ring {
background: radial-gradient(circle, rgba(50, 255, 50, 0.4) 0%, transparent 70%);
box-shadow: 0 0 60px 20px rgba(50, 255, 50, 0.3), inset 0 0 60px 20px rgba(50, 255, 50, 0.1);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.08); opacity: 0.85; }
}
#status-text {
margin-top: 20px;
font-size: 1.2rem;
opacity: 0.8;
}
.btn {
margin-top: 16px;
padding: 10px 28px;
font-size: 1rem;
border: none;
border-radius: 8px;
background: #16213e;
color: #fff;
cursor: pointer;
transition: background 0.2s;
}
.btn:hover {
background: #0f3460;
}
.hint {
margin-top: 24px;
font-size: 0.85rem;
opacity: 0.5;
}
.error {
margin-top: 12px;
color: #ff6b6b;
font-size: 0.9rem;
}
.hidden {
display: none;
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'vite'
export default defineConfig({
assetsInlineLimit: 0,
})
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+45
View File
@@ -0,0 +1,45 @@
# Rubber Duck MCP
MCP-сервер для техники «резиновая уточка»: когда ИИ застревает в логическом цикле или склонен к
галлюцинациям, он вызывает инструмент `quack()`, чтобы сформулировать мысль и продолжить.
Сервер работает в режиме **Streamable HTTP** (stateless), развёртывается на Vercel, использует
`mcp-handler` v2 + `@modelcontextprotocol/server` v2.
## Эндпоинт
```
POST /api/mcp
```
Подключите этот URL как MCP-сервер (тип Streamable HTTP) в Cursor, Claude Desktop и других клиентах.
## Инструменты
- `quack` — возвращает кряк. Опциональный параметр `mood`: `happy | confused | excited | sleepy`.
## Запуск
```bash
pnpm install
pnpm dev # http://localhost:3000
pnpm build
pnpm start
```
## Проверка
```bash
curl -X POST http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"quack","arguments":{"mood":"happy"}}}'
```
## Деплой на Vercel
```bash
vercel --prod
```
(`rootDirectory` проекта — `apps/mcp`.)
+21
View File
@@ -0,0 +1,21 @@
import { createMcpHandler } from "mcp-handler";
import { registerDuckTools } from "@/lib/mcp";
export const runtime = "nodejs";
const post = createMcpHandler(
async (server) => {
registerDuckTools(server);
},
{
serverInfo: { name: "rubber-duck-mcp", version: "1.0.0" },
},
);
export async function GET(req: Request) {
return post(req);
}
export async function POST(req: Request) {
return post(req);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+49
View File
@@ -0,0 +1,49 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html {
height: 100%;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
min-height: 100%;
display: flex;
flex-direction: column;
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Rubber Duck MCP",
description: "MCP server for rubber duck debugging — quack() to articulate your thinking.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+23
View File
@@ -0,0 +1,23 @@
export default function Home() {
return (
<main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 640, margin: "0 auto", padding: "3rem 1rem" }}>
<h1>Rubber Duck MCP</h1>
<p>
MCP-сервер для техники «резиновая уточка»: когда ИИ застревает, он вызывает инструмент{" "}
<code>quack()</code>, чтобы сформулировать мысль и продолжить. Сервер работает в режиме
Streamable HTTP и предназначен для подключения в Cursor, Claude Desktop и других MCP-клиентах.
</p>
<h2>Эндпоинт</h2>
<p>
<code>/api/mcp</code> подключите этот URL как MCP-сервер (тип Streamable HTTP).
</p>
<h2>Инструменты</h2>
<ul>
<li>
<code>quack</code> возвращает кряк. Опциональный параметр <code>mood</code>:{" "}
<code>happy | confused | excited | sleepy</code>.
</li>
</ul>
</main>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
const QUACKS = {
happy: ["QUACK! 🎉", "Quack quack! 😄", "QUAAACK! 🦆✨"],
confused: ["Qu...ack?", "Quack? 🤔", "Quaaack...?"],
excited: ["QUACK QUACK QUACK!", "QUAAACK!! 🔥", "Quack! Quack! Quack!"],
sleepy: ["quack... 😴", "quaack... 💤", "quack. *yawn*"],
default: ["Quack!", "Quack quack!", "QUACK!", "Quaaack!", "Quack. 🦆"],
} as const;
const MOODS = ["happy", "confused", "excited", "sleepy"] as const;
function randomFrom<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
export function registerDuckTools(server: McpServer): void {
server.registerTool(
"quack",
{
title: "Quack",
description:
"Rubber duck quacks. Use when you're stuck, looping, or hallucinating. " +
"The act of calling forces you to articulate your current thinking out loud.",
inputSchema: z.object({
mood: z
.enum(MOODS)
.optional()
.describe("Mood of the duck. Omit for a random quack."),
}),
},
async ({ mood }) => {
const text = mood ? randomFrom(QUACKS[mood]) : randomFrom(QUACKS.default);
return { content: [{ type: "text", text }] };
},
);
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+25
View File
@@ -0,0 +1,25 @@
{
"name": "mcp",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@modelcontextprotocol/server": "^2.0.0",
"mcp-handler": "^2.1.1",
"next": "16.3.3",
"react": "19.2.8",
"react-dom": "19.2.8",
"zod": "^4.5.4"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5"
},
"packageManager": "pnpm@11.20.0"
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}