File size: 15,540 Bytes
4692a30 765fe0a 4692a30 765fe0a 4692a30 765fe0a 4692a30 765fe0a 4692a30 765fe0a 4692a30 765fe0a 1a925d4 765fe0a 1f82101 765fe0a 1f82101 765fe0a 1f82101 765fe0a 1f82101 765fe0a 1f82101 765fe0a 1f82101 765fe0a 1f82101 765fe0a 1f82101 765fe0a 1f82101 765fe0a 1f82101 765fe0a 4692a30 765fe0a 4692a30 765fe0a 4692a30 765fe0a 4692a30 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 | #!/usr/bin/env node
// Agent Web's own MCP server (stdio, newline-delimited JSON-RPC 2.0).
// - share_file: hands files to the user; the web server sees the result and
// shows them in the chat as download cards (see server/chat.js).
// - note_file: a short note about a file for the project map
// (.agentweb/notes.json, see server/memory.js), so it is not re-read later.
// - ask_gemini: delegates research / big-file / image analysis to the Gemini
// CLI (listed only when it is installed and signed in).
// No dependencies on purpose: it starts fast and needs nothing from npm.
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const readline = require('readline');
const { execFile } = require('child_process');
let memory = null;
let redact = null;
try {
memory = require(path.join(__dirname, '..', 'server', 'memory.js'));
redact = require(path.join(__dirname, '..', 'server', 'redact.js'));
} catch {
memory = null;
}
const SERVER_INFO = { name: 'agentweb', version: '2.0.0' };
const GEMINI_TIMEOUT_MS = 6 * 60 * 1000;
const MAX_GEMINI_ANSWER = 60000;
const MAX_GEMINI_FILES = 20;
const MAX_SHARE_FILES = 10;
const MAX_SHARE_BYTES = 50 * 1024 * 1024;
const MAX_SHARE_TOTAL = 100 * 1024 * 1024;
const SYSTEM_PATH_RE = /^\/(proc|sys|dev)(\/|$)/;
const TOOLS = [
{
name: 'share_file',
title: 'Dosya paylaş',
description:
'Send files from this machine to the user: they appear in the Agent Web chat as download cards ' +
'(images also get a preview). Use it whenever the user asks you to send, share or give them a file, ' +
'or when a result is better delivered as a file than as pasted text (long code, logs, CSV, documents, ' +
'archives, images). Write the file first, then call this with its path. Folders cannot be shared; ' +
'archive them first (zip/tar). At most 10 files, 50 MB each.',
inputSchema: {
type: 'object',
properties: {
paths: {
type: 'array',
items: { type: 'string' },
minItems: 1,
maxItems: MAX_SHARE_FILES,
description: 'File paths, absolute or relative to the current working directory.',
},
},
required: ['paths'],
additionalProperties: false,
},
annotations: { title: 'Dosya paylaş', readOnlyHint: true, openWorldHint: false },
},
{
name: 'note_file',
title: 'Dosya notu',
description:
'Save a 1-3 line note about what a file contains (its purpose, main parts, where the important logic is). ' +
'The note appears next to the file in .agentweb/MAP.md, so later conversations know the file without reading it again. ' +
'Call it after reading and understanding a large or central file. An empty note deletes it.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path, relative to the working directory (or absolute inside it).' },
note: { type: 'string', description: 'The note, at most ~300 characters.' },
},
required: ['path', 'note'],
additionalProperties: false,
},
annotations: { title: 'Dosya notu', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
},
];
const GEMINI_TOOL = {
name: 'ask_gemini',
title: "Gemini'ye sor",
description:
"Ask Google Gemini (via the Gemini CLI, read-only mode) and get its answer back. It has a 1M-token context, Google Search, and reads images, PDFs, audio and video. " +
'Use it to save the user\'s Claude usage on: web research (current facts, docs, comparisons), reading/summarising very large files or logs, a first overview of a big codebase, ' +
'and analysing images/screenshots/PDFs/media. Pass the relevant files in `files` (paths relative to the working directory). ' +
'Ask a precise question and say what form of answer you need; verify important claims and never include secrets.',
inputSchema: {
type: 'object',
properties: {
prompt: { type: 'string', description: 'The question or task for Gemini, with all needed context.' },
files: { type: 'array', items: { type: 'string' }, maxItems: MAX_GEMINI_FILES, description: 'Files (or folders) Gemini should read: code, logs, images, PDFs, audio, video.' },
model: { type: 'string', description: 'Optional Gemini model id (default: the CLI default).' },
},
required: ['prompt'],
additionalProperties: false,
},
annotations: { title: "Gemini'ye sor", readOnlyHint: true, openWorldHint: true },
};
class ToolError extends Error {}
function formatBytes(n) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function text(value) {
return { type: 'text', text: value };
}
function shareFile(args) {
let raw = args.paths;
if (typeof raw === 'string') raw = [raw];
if (!Array.isArray(raw) || !raw.length) throw new ToolError('paths en az bir dosya yolu içermeli.');
if (raw.length > MAX_SHARE_FILES) throw new ToolError(`Tek seferde en fazla ${MAX_SHARE_FILES} dosya paylaşılabilir.`);
const files = [];
let total = 0;
for (const value of raw) {
if (typeof value !== 'string' || !value.trim() || value.includes('\0')) throw new ToolError('Geçersiz dosya yolu.');
const expanded = value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value;
const abs = path.resolve(process.cwd(), expanded);
let stat;
try {
stat = fs.statSync(abs);
} catch {
throw new ToolError(`Dosya bulunamadı: ${value}`);
}
if (stat.isDirectory()) throw new ToolError(`${value} bir klasör; önce zip ya da tar ile arşivleyip arşivi paylaşın.`);
if (!stat.isFile()) throw new ToolError(`${value} normal bir dosya değil.`);
// /proc and friends look like regular files but hold live process state
// (environment variables, memory maps) that should never leave the box.
if (SYSTEM_PATH_RE.test(fs.realpathSync(abs))) throw new ToolError(`${value} bir sistem dosyası; paylaşılamaz.`);
if (stat.size > MAX_SHARE_BYTES) throw new ToolError(`${value} çok büyük (${formatBytes(stat.size)}; en fazla 50 MB).`);
try {
fs.accessSync(abs, fs.constants.R_OK);
} catch {
throw new ToolError(`${value} okunamıyor (izin yok).`);
}
total += stat.size;
if (total > MAX_SHARE_TOTAL) throw new ToolError('Dosyaların toplamı 100 MB\'ı aşıyor; daha az dosya paylaşın.');
files.push({ path: fs.realpathSync(abs), name: path.basename(abs), size: stat.size });
}
const summary = files.map((f) => `${f.name} (${formatBytes(f.size)})`).join(', ');
return [
text(`Paylaşıldı: ${summary}. Agent Web sohbetinde indirme kartı olarak görünür.`),
text(JSON.stringify({ agentwebShare: files })),
];
}
function noteFile(args) {
if (!memory) throw new ToolError('Not kaydedilemedi: Agent Web modülü bulunamadı.');
if (typeof args.path !== 'string' || !args.path.trim()) throw new ToolError('path gerekli.');
if (typeof args.note !== 'string') throw new ToolError('note gerekli.');
let key;
try {
key = memory.saveNote(process.cwd(), args.path.trim(), args.note);
} catch (err) {
throw new ToolError(err.message);
}
return [text(args.note.trim() ? `Not kaydedildi: ${key}` : `Not silindi: ${key}`)];
}
function onPath(name) {
for (const dir of String(process.env.PATH || '').split(':')) {
if (!dir) continue;
const candidate = path.join(dir, name);
try {
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch {
// yok
}
}
return null;
}
function geminiReady() {
// Set for Gemini's own copy of this server (see setup-mcp.js): Gemini need not ask itself.
if (process.env.AGENTWEB_MCP_NO_GEMINI === '1') return false;
try {
const prefs = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.cc-web', 'prefs.json'), 'utf8'));
if (prefs && prefs.gemini === false) return false;
} catch {
// varsayilan: acik
}
if (agyBin()) return true;
if (!onPath('gemini')) return false;
return !!(process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY);
}
// Antigravity CLI, once signed in (Google account / AI Pro subscription).
// Gemini CLI only works with an API key since Google moved personal accounts.
function agyBin() {
const bin = onPath('agy');
return bin && fs.existsSync(path.join(os.homedir(), '.gemini', 'antigravity-cli', 'antigravity-oauth-token')) ? bin : null;
}
function askGemini(args) {
const agy = agyBin();
const bin = agy || onPath('gemini');
if (!bin) throw new ToolError('Gemini için ne Antigravity CLI (agy) ne Gemini CLI kurulu.');
let prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
if (!prompt) throw new ToolError('prompt gerekli.');
if (redact) prompt = redact.redactText(prompt);
const files = Array.isArray(args.files) ? args.files.filter((f) => typeof f === 'string' && f.trim()).slice(0, MAX_GEMINI_FILES) : [];
const refs = [];
for (const f of files) {
const abs = path.resolve(process.cwd(), f.startsWith('~/') ? path.join(os.homedir(), f.slice(2)) : f);
if (!fs.existsSync(abs)) throw new ToolError(`Dosya bulunamadı: ${f}`);
if (SYSTEM_PATH_RE.test(fs.realpathSync(abs))) throw new ToolError(`${f} bir sistem dosyası.`);
refs.push(agy ? abs : `@${abs.replace(/ /g, '\\ ')}`);
}
const full = [
'You are assisting another AI agent (Claude) that is coding for a user. Answer precisely and concisely (normally under ~700 words unless the task needs more).',
'For web research, cite the source URLs. Say clearly when you are unsure. Do not modify anything.',
'',
prompt,
refs.length ? `\nFiles: ${refs.join(agy ? ', ' : ' ')}` : '',
].join('\n');
const model = typeof args.model === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(args.model) ? args.model : '';
let argv;
if (agy) {
argv = [`--print=${full}`, '--mode', 'plan', '--output-format', 'json'];
// agy model names carry the effort (e.g. gemini-3.8-flash-low).
if (/-(low|medium|high)$/.test(model)) argv.push('--model', model);
} else {
argv = ['-p', full, '--approval-mode', 'plan', '--output-format', 'json', '--skip-trust'];
if (model) argv.push('-m', model);
}
return new Promise((resolve, reject) => {
const child = execFile(bin, argv, { cwd: process.cwd(), env: process.env, timeout: GEMINI_TIMEOUT_MS, maxBuffer: 20 * 1024 * 1024 }, (err, stdout, stderr) => {
let parsed = null;
// The JSON answer, without any log lines printed around it.
const raw = String(stdout || '');
const from = raw.search(/^\{/m);
const to = raw.lastIndexOf('}');
try {
parsed = from >= 0 && to > from ? JSON.parse(raw.slice(from, to + 1)) : null;
} catch {
parsed = null;
}
if (parsed && parsed.error) {
const msg = parsed.error.message || String(parsed.error);
reject(new ToolError(/auth method|login|credentials|authentication|sign in|Eligibility/i.test(msg) ? `Gemini'ye giriş yapılmamış: Agent Web Terminal sekmesinde \`agy\` çalıştırıp Google ile giriş yap (ya da GEMINI_API_KEY secret'ı ekle). Ayrıntı: ${msg}` : `Gemini hatası: ${msg}`));
return;
}
if (parsed && typeof parsed.response === 'string') {
let answer = parsed.response.trim();
if (answer.length > MAX_GEMINI_ANSWER) answer = `${answer.slice(0, MAX_GEMINI_ANSWER)}\n… (yanıt kırpıldı)`;
let usage = parsed.stats && parsed.stats.models ? Object.entries(parsed.stats.models).map(([m, s]) => `${m}: ${(s.tokens && s.tokens.total) || '?'} token`).join(', ') : '';
if (!usage && parsed.usage && parsed.usage.total_tokens) usage = `${parsed.usage.total_tokens} token`;
resolve([text(answer || '(Gemini boş yanıt verdi)'), ...(usage ? [text(`[Gemini kullanımı: ${usage}]`)] : [])]);
return;
}
if (err) {
reject(new ToolError(err.killed ? 'Gemini zaman aşımına uğradı (6 dk).' : `Gemini çalışmadı: ${String(stderr || err.message).trim().split('\n').slice(-3).join(' ')}`));
return;
}
resolve([text(String(stdout || '').trim().slice(0, MAX_GEMINI_ANSWER) || '(boş yanıt)')]);
});
if (child.stdin) child.stdin.end();
});
}
const HANDLERS = {
share_file: shareFile,
note_file: noteFile,
ask_gemini: askGemini,
};
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function listedTools() {
const tools = memory ? TOOLS : TOOLS.filter((t) => t.name !== 'note_file');
return geminiReady() ? [...tools, GEMINI_TOOL] : tools;
}
async function handle(message) {
const { id, method, params } = message;
const isRequest = id !== undefined && id !== null;
if (!method) return;
if (!isRequest) return; // notifications (initialized, cancelled, ...)
try {
switch (method) {
case 'initialize': {
const requested = params && params.protocolVersion;
send({
jsonrpc: '2.0',
id,
result: {
protocolVersion: /^\d{4}-\d{2}-\d{2}$/.test(requested || '') ? requested : '2025-06-18',
capabilities: { tools: { listChanged: false } },
serverInfo: SERVER_INFO,
instructions: 'Agent Web tools. share_file sends files to the user in the chat; note_file records what a file contains for the project map; ask_gemini (when listed) delegates research and media/large-file analysis to Gemini.',
},
});
return;
}
case 'ping':
send({ jsonrpc: '2.0', id, result: {} });
return;
case 'tools/list':
send({ jsonrpc: '2.0', id, result: { tools: listedTools() } });
return;
case 'tools/call': {
const name = params && params.name;
const listed = listedTools().some((tool) => tool.name === name);
const handler = listed && Object.prototype.hasOwnProperty.call(HANDLERS, name) ? HANDLERS[name] : null;
if (!handler) {
send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${name}` } });
return;
}
try {
const content = await handler((params && params.arguments) || {});
send({ jsonrpc: '2.0', id, result: { content } });
} catch (err) {
const messageText = err instanceof ToolError ? err.message : `Hata: ${err && err.message ? err.message : String(err)}`;
send({ jsonrpc: '2.0', id, result: { content: [text(messageText)], isError: true } });
}
return;
}
default:
send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
}
} catch (err) {
send({ jsonrpc: '2.0', id, error: { code: -32603, message: err && err.message ? err.message : String(err) } });
}
}
if (require.main === module) {
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
rl.on('line', (line) => {
if (!line.trim()) return;
let message;
try {
message = JSON.parse(line);
} catch {
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
return;
}
for (const item of Array.isArray(message) ? message : [message]) {
if (item && typeof item === 'object') handle(item);
}
});
rl.on('close', () => process.exit(0));
}
module.exports = { TOOLS, HANDLERS };
|