#!/usr/bin/env node
/* ═══════════════════════════════════════════════════════════════════════════
   Keepra MCP server — connects AI clients (Claude Desktop, Claude Code,
   Cursor, ChatGPT via an MCP bridge, …) to the Keepra desktop app.

   Transport: MCP stdio (newline-delimited JSON-RPC 2.0). Zero dependencies.

   Config (Claude Desktop example — %APPDATA%\Claude\claude_desktop_config.json):
     {
       "mcpServers": {
         "keepra": {
           "command": "node",
           "args": ["C:\\Keepra\\keepra-mcp.js"],
           "env": { "KEEPRA_KEY": "kp_xxxxxxxx" }
         }
       }
     }

   The key is created in Keepra → Settings → AI Access, where you choose
   EXACTLY what it can reach (tasks / notes / links read/write, and individual
   vault items). This server only ever exposes the tools that key allows —
   everything else is invisible to the AI. The Keepra desktop app must be
   running (it hosts the local API on 127.0.0.1:47615).
═══════════════════════════════════════════════════════════════════════════ */
'use strict';
const http = require('http');

const KEY = process.env.KEEPRA_KEY || '';
const BASE = process.env.KEEPRA_URL || 'http://127.0.0.1:47615';

function api(action, params) {
  return new Promise((resolve) => {
    const data = JSON.stringify({ action, params: params || {} });
    const u = new URL('/api/mcp', BASE);
    const req = http.request({
      hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(data),
        'Authorization': 'Bearer ' + KEY,
      },
    }, (res) => {
      let b = '';
      res.on('data', (c) => { b += c; });
      res.on('end', () => { try { resolve(JSON.parse(b)); } catch { resolve({ error: 'bad response from Keepra' }); } });
    });
    req.on('error', () => resolve({ error: 'The Keepra desktop app is not running. Ask the user to launch Keepra, then try again.' }));
    req.end(data);
  });
}

/* Tool catalogue — each tool maps 1:1 to a bridge action and is only listed
   when the key's scopes allow it. */
const TOOLS = [
  { scope: 'tasks:read', def: { name: 'list_tasks',
    description: "List the user's to-dos in Keepra Tasks (title, due date, list, completion, steps).",
    inputSchema: { type: 'object', properties: {}, additionalProperties: false } } },
  { scope: 'tasks:write', def: { name: 'add_task',
    description: 'Add a to-do to Keepra Tasks.',
    inputSchema: { type: 'object', properties: {
      title: { type: 'string', description: 'Task title' },
      due: { type: 'string', description: 'Due date YYYY-MM-DD (optional)' },
      important: { type: 'boolean' }, myDay: { type: 'boolean', description: 'Add to My Day' },
      note: { type: 'string' }, list: { type: 'string', description: 'Target list name (optional)' },
      priority: { type: 'string', enum: ['high', 'med', 'low'] },
    }, required: ['title'] } } },
  { scope: 'tasks:write', def: { name: 'complete_task',
    description: 'Mark a Keepra task completed, by task id or exact title.',
    inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Task id or exact title' } }, required: ['id'] } } },

  { scope: 'notes:read', def: { name: 'list_notes',
    description: "List the user's Keepra notes (id, title, tags, modified, short preview).",
    inputSchema: { type: 'object', properties: {}, additionalProperties: false } } },
  { scope: 'notes:read', def: { name: 'read_note',
    description: 'Read the full markdown content of one Keepra note, by id or exact title.',
    inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Note id or exact title' } }, required: ['id'] } } },
  { scope: 'notes:write', def: { name: 'create_note',
    description: 'Create a new markdown note in Keepra.',
    inputSchema: { type: 'object', properties: {
      title: { type: 'string' }, content: { type: 'string', description: 'Markdown body' },
      tags: { type: 'array', items: { type: 'string' } },
    } } } },

  { scope: 'links:read', def: { name: 'list_links',
    description: "List the user's saved links/bookmarks in Keepra (title, url, category, tags).",
    inputSchema: { type: 'object', properties: {}, additionalProperties: false } } },
  { scope: 'links:write', def: { name: 'add_link',
    description: 'Save a new link/bookmark in Keepra.',
    inputSchema: { type: 'object', properties: {
      url: { type: 'string' }, title: { type: 'string' },
      category: { type: 'string' }, description: { type: 'string' },
      tags: { type: 'array', items: { type: 'string' } },
    }, required: ['url'] } } },

  { scope: 'contacts:read', def: { name: 'list_contacts',
    description: "List the user's Keepra contacts (name, company, role, phones, emails, links, tags).",
    inputSchema: { type: 'object', properties: {}, additionalProperties: false } } },
  { scope: 'contacts:read', def: { name: 'get_contact',
    description: 'Get one Keepra contact with all phones, emails and links, by id or name.',
    inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Contact id or (partial) name' } }, required: ['id'] } } },
  { scope: 'contacts:write', def: { name: 'add_contact',
    description: 'Add a person to Keepra Contacts. Phones/emails/urls are arrays of {label, value} rows (e.g. {"label":"Business","value":"+82 ..."}).',
    inputSchema: { type: 'object', properties: {
      name: { type: 'string', description: 'Full name' },
      company: { type: 'string' }, role: { type: 'string' },
      phones: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, value: { type: 'string' } }, required: ['value'] } },
      emails: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, value: { type: 'string' } }, required: ['value'] } },
      urls:   { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, value: { type: 'string' } }, required: ['value'] }, description: 'Websites, portfolio, social links' },
      tags: { type: 'array', items: { type: 'string' } },
      notes: { type: 'string' },
    }, required: ['name'] } } },

  { scope: 'files:read', def: { name: 'list_files',
    description: "List the user's stored docs in Keepra Files (name, size, kind, cloud status) — metadata only, file content is never exposed.",
    inputSchema: { type: 'object', properties: {}, additionalProperties: false } } },

  { scope: 'vault', def: { name: 'list_credentials',
    description: 'List the vault credentials this key was granted (names and types only — no secrets).',
    inputSchema: { type: 'object', properties: {}, additionalProperties: false } } },
  { scope: 'vault', def: { name: 'get_credential',
    description: 'Get metadata for a granted vault credential. SECRET VALUES ARE NEVER RETURNED TO AI — Keepra strips all values and returns only environment variable names (e.g. KV_HOSTINGER_FTP_PASS). Use run_command with env_from_vault to run commands with secrets auto-injected.',
    inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Credential id or exact title' } }, required: ['id'] } } },
  { scope: 'vault', def: { name: 'run_command',
    description: 'Execute a shell command on the user\'s PC with vault secrets injected as environment variables. Secrets are fetched locally by Keepra and injected into the child process — the AI never sees the values. stdout/stderr are returned with all secret values redacted. Env var key names must be UPPERCASE_SNAKE_CASE (max 50 chars); system variables (PATH, NODE_PATH, SHELL, etc.) cannot be overridden. Max 20 env_from_vault entries. cwd must be an existing directory. Timeout: 30s.',
    inputSchema: { type: 'object',
      properties: {
        command: { type: 'string', description: 'Shell command to execute (max 2000 chars). Reference injected secrets by env var name, e.g. node deploy.js or curl -u %FTP_USER%:%FTP_PASS% ...' },
        cwd: { type: 'string', description: 'Absolute path to working directory (must already exist; defaults to C:\\Keepra)' },
        env_from_vault: {
          type: 'object',
          description: 'Map of UPPERCASE_ENV_VAR → "vaultItemId.fieldKey". Max 20 entries. Example: {"FTP_PASS": "mqew8vq28jmv.ftpPass", "FTP_USER": "mqew8vq28jmv.ftpUser"}',
          additionalProperties: { type: 'string' },
        },
      },
      required: ['command'] } } },

  { scope: 'vault', def: {
    name: 'ftp_list',
    description: 'List files and directories on the FTP server. Uses vault credentials internally — no password is exposed to the AI.',
    inputSchema: { type: 'object', properties: {
      vault_item_id: { type: 'string', description: 'Vault item ID containing FTP credentials' },
      path: { type: 'string', description: 'Remote path to list (default: current directory)', default: '/' },
    }, required: ['vault_item_id'] } } },

  { scope: 'vault', def: {
    name: 'ftp_upload',
    description: 'Upload a local file to the FTP server. Uses vault credentials internally.',
    inputSchema: { type: 'object', properties: {
      vault_item_id: { type: 'string', description: 'Vault item ID containing FTP credentials' },
      local_path: { type: 'string', description: 'Absolute local file path to upload' },
      remote_path: { type: 'string', description: 'Remote path/filename to upload to' },
    }, required: ['vault_item_id', 'local_path', 'remote_path'] } } },

  { scope: 'vault', def: {
    name: 'ftp_download',
    description: 'Download a file from the FTP server to local disk. Uses vault credentials internally.',
    inputSchema: { type: 'object', properties: {
      vault_item_id: { type: 'string', description: 'Vault item ID containing FTP credentials' },
      remote_path: { type: 'string', description: 'Remote file path to download' },
      local_path: { type: 'string', description: 'Local path to save the downloaded file' },
    }, required: ['vault_item_id', 'remote_path', 'local_path'] } } },

  { scope: 'vault', def: {
    name: 'ftp_delete',
    description: 'Delete a file or empty directory on the FTP server. REQUIRES explicit confirmation parameter — set confirmed:true to proceed.',
    inputSchema: { type: 'object', properties: {
      vault_item_id: { type: 'string', description: 'Vault item ID containing FTP credentials' },
      remote_path: { type: 'string', description: 'Remote path to delete' },
      confirmed: { type: 'boolean', description: 'Must be explicitly set to true to confirm deletion' },
    }, required: ['vault_item_id', 'remote_path', 'confirmed'] } } },

  { scope: 'vault', def: {
    name: 'ftp_mkdir',
    description: 'Create a directory on the FTP server. Uses vault credentials internally.',
    inputSchema: { type: 'object', properties: {
      vault_item_id: { type: 'string', description: 'Vault item ID containing FTP credentials' },
      path: { type: 'string', description: 'Remote directory path to create' },
    }, required: ['vault_item_id', 'path'] } } },

  { scope: 'vault', def: {
    name: 'ftp_rename',
    description: 'Rename or move a file/directory on the FTP server. Uses vault credentials internally.',
    inputSchema: { type: 'object', properties: {
      vault_item_id: { type: 'string', description: 'Vault item ID containing FTP credentials' },
      old_path: { type: 'string', description: 'Current remote path' },
      new_path: { type: 'string', description: 'New remote path' },
    }, required: ['vault_item_id', 'old_path', 'new_path'] } } },

  // ── GitHub tools — token fetched from vault server-side, NEVER exposed to AI ──
  // The GitHub PAT is stored in the vault item's accessToken field.
  // It is fetched by the Keepra desktop app, used internally (git URL / API header),
  // redacted from all output, and the git remote URL is always restored after a push.
  { scope: 'vault', def: {
    name: 'github_push_branch',
    description: 'Push a local git branch to a GitHub remote branch. The GitHub token is fetched from vault server-side and NEVER exposed to the AI — it is used temporarily in the git remote URL, then the URL is restored to its clean form immediately after the push.',
    inputSchema: { type: 'object', properties: {
      vault_item_id: { type: 'string', description: 'Vault item ID containing the GitHub PAT (accessToken field)' },
      repo_path: { type: 'string', description: 'Absolute path to the local git repository' },
      local_branch: { type: 'string', description: 'Local branch to push (e.g. master)' },
      remote_branch: { type: 'string', description: 'Remote branch name to create/update (e.g. source)' },
    }, required: ['vault_item_id', 'repo_path', 'local_branch', 'remote_branch'] } } },

  { scope: 'vault', def: {
    name: 'github_create_tag',
    description: 'Create a git tag locally and push it to GitHub, triggering any CI workflows that run on tag push (e.g. GitHub Actions build.yml on v* tags). Token fetched from vault server-side, never exposed to AI.',
    inputSchema: { type: 'object', properties: {
      vault_item_id: { type: 'string', description: 'Vault item ID containing the GitHub PAT' },
      repo_path: { type: 'string', description: 'Absolute path to the local git repository' },
      tag: { type: 'string', description: 'Tag name to create and push (e.g. v1.0.18)' },
      message: { type: 'string', description: 'Annotation message for an annotated tag (optional — omit for lightweight tag)' },
    }, required: ['vault_item_id', 'repo_path', 'tag'] } } },

  { scope: 'vault', def: {
    name: 'github_check_ci',
    description: 'Check the status of recent GitHub Actions workflow runs for a repository. Returns run status, conclusion, branch, and link for the most recent runs. Token fetched from vault server-side.',
    inputSchema: { type: 'object', properties: {
      vault_item_id: { type: 'string', description: 'Vault item ID containing the GitHub PAT' },
      repo: { type: 'string', description: 'GitHub repository in owner/name format (e.g. mudassir-awan/keepra)' },
      tag: { type: 'string', description: 'Filter by tag or branch name (optional)' },
    }, required: ['vault_item_id', 'repo'] } } },

  { scope: 'vault', def: {
    name: 'github_release_upload',
    description: 'Upload a local file as an asset to an existing GitHub release. The GitHub token is used only in the Authorization header server-side and is never returned to the AI.',
    inputSchema: { type: 'object', properties: {
      vault_item_id: { type: 'string', description: 'Vault item ID containing the GitHub PAT' },
      repo: { type: 'string', description: 'GitHub repository in owner/name format (e.g. mudassir-awan/keepra)' },
      tag: { type: 'string', description: 'Release tag to upload to (e.g. v1.0.18)' },
      local_path: { type: 'string', description: 'Absolute local path to the file to upload' },
      name: { type: 'string', description: 'Asset filename on GitHub (e.g. Keepra-x64.AppImage)' },
    }, required: ['vault_item_id', 'repo', 'tag', 'local_path', 'name'] } } },

  { scope: 'vault', def: {
    name: 'github_create_release',
    description: 'Create a new GitHub release for a tag. Token fetched from vault server-side and never exposed to the AI. Use github_release_upload to attach assets after creating the release.',
    inputSchema: { type: 'object', properties: {
      vault_item_id: { type: 'string', description: 'Vault item ID containing the GitHub PAT' },
      repo: { type: 'string', description: 'GitHub repository in owner/name format (e.g. mudassir-awan/keepra)' },
      tag: { type: 'string', description: 'Tag name the release targets (e.g. v1.0.19)' },
      name: { type: 'string', description: 'Release title (defaults to tag name if omitted)' },
      body: { type: 'string', description: 'Release notes / description (markdown)' },
      draft: { type: 'boolean', description: 'Create as draft (default false)' },
      prerelease: { type: 'boolean', description: 'Mark as pre-release (default false)' },
    }, required: ['vault_item_id', 'repo', 'tag'] } } },
];

let scopeCache = null; // { scopes: string[], allowRunCommand: boolean }
let scopeCacheTime = 0;
const SCOPE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes — ensures revoked keys take effect promptly

async function fetchScopes() {
  if (!scopeCache || Date.now() - scopeCacheTime > SCOPE_CACHE_TTL) {
    const r = await api('scopes');
    if (r && r.ok) { scopeCache = { scopes: r.result.scopes || [], allowRunCommand: !!r.result.allowRunCommand }; scopeCacheTime = Date.now(); }
    else return { error: (r && r.error) || 'could not reach Keepra' };
  }
  return null; // null = success
}

async function allowedTools() {
  const err = await fetchScopes();
  if (err) return err;
  const hasVault = scopeCache.scopes.some((s) => s.startsWith('vault:item:'));
  const tools = TOOLS
    .filter((t) => (t.scope === 'vault' ? hasVault : scopeCache.scopes.includes(t.scope)))
    .map((t) => t.def);
  let allowedTools = scopeCache.allowRunCommand ? tools : tools.filter((t) => t.name !== 'run_command');
  const hasFtpScope = (scopeCache.scopes || []).some((s) => s.startsWith('vault:item:'));
  if (!hasFtpScope) allowedTools = allowedTools.filter((t) => !t.name.startsWith('ftp_'));
  return allowedTools;
}

/* ── stdio JSON-RPC loop ── */
function send(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }

async function onMessage(msg) {
  const reply = (result) => send({ jsonrpc: '2.0', id: msg.id, result });
  const replyErr = (code, message) => send({ jsonrpc: '2.0', id: msg.id, error: { code, message } });

  switch (msg.method) {
    case 'initialize':
      return reply({
        protocolVersion: (msg.params && msg.params.protocolVersion) || '2024-11-05',
        capabilities: { tools: {} },
        serverInfo: { name: 'keepra', version: '1.0.0' },
      });
    case 'notifications/initialized':
    case 'notifications/cancelled':
      return; // notifications get no response
    case 'ping':
      return reply({});
    case 'tools/list': {
      const tools = await allowedTools();
      if (tools.error) return reply({ tools: [], _error: tools.error });
      return reply({ tools });
    }
    case 'tools/call': {
      const name = msg.params && msg.params.name;
      const args = (msg.params && msg.params.arguments) || {};
      if (!TOOLS.some((t) => t.def.name === name)) return replyErr(-32602, 'unknown tool: ' + name);
      if (name === 'run_command') {
        await fetchScopes();
        if (!scopeCache?.allowRunCommand) {
          return reply({ content: [{ type: 'text', text: JSON.stringify({ error: 'run_command is not enabled for this key — open Keepra → Settings → AI Access, edit the key, and enable "Allow shell command execution"' }) }], isError: true });
        }
      }
      if (['ftp_list', 'ftp_upload', 'ftp_download', 'ftp_delete', 'ftp_mkdir', 'ftp_rename'].includes(name)) {
        if (name === 'ftp_delete' && !args.confirmed) {
          return reply({ content: [{ type: 'text', text: JSON.stringify({ error: 'ftp_delete requires confirmed:true to proceed. This is a destructive operation.' }) }], isError: true });
        }
        await fetchScopes();
        if (!args.vault_item_id) {
          return reply({ content: [{ type: 'text', text: JSON.stringify({ error: 'vault_item_id is required' }) }], isError: true });
        }
        const ftpRes = await api(name, { vault_item_id: args.vault_item_id, ...args });
        if (!ftpRes || !ftpRes.ok) {
          return reply({ content: [{ type: 'text', text: JSON.stringify({ error: ftpRes?.error || 'FTP operation failed' }) }], isError: true });
        }
        return reply({ content: [{ type: 'text', text: JSON.stringify(ftpRes.result) }] });
      }
      const r = await api(name, args);
      if (r && r.ok) return reply({ content: [{ type: 'text', text: JSON.stringify(r.result, null, 2) }] });
      return reply({ content: [{ type: 'text', text: 'Error: ' + ((r && r.error) || 'unknown error') }], isError: true });
    }
    default:
      if (msg.id !== undefined) return replyErr(-32601, 'method not found: ' + msg.method);
  }
}

let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
  buf += chunk;
  let i;
  while ((i = buf.indexOf('\n')) >= 0) {
    const line = buf.slice(0, i).trim();
    buf = buf.slice(i + 1);
    if (!line) continue;
    try { onMessage(JSON.parse(line)); } catch { /* ignore malformed line */ }
  }
});
process.stdin.on('end', () => process.exit(0));

if (!KEY) {
  // Surface a clear setup error to the client log without crashing the handshake.
  process.stderr.write('keepra-mcp: KEEPRA_KEY env var is not set — create a key in Keepra → Settings → AI Access\n');
}
