← back to Kimi Mcp

server.mjs

126 lines

#!/usr/bin/env node
// kimi-mcp — owned MCP server exposing Moonshot Kimi as consult tools.
// Scope: consult only (ask / review). Kimi gets NO filesystem, bash, or agent
// loop here — it answers, it does not act. Model + temperature match the /dtd
// panel (kimi-k2.5, temp=1) so this is the same Kimi lens Steve already trusts.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

// ── Constants ────────────────────────────────────────────────────────────────
const MOONSHOT_API_KEY = process.env.MOONSHOT_API_KEY;
const MOONSHOT_BASE = 'https://api.moonshot.ai/v1';           // global endpoint (matches /dtd), NOT .cn
const MODEL = process.env.KIMI_MODEL || 'kimi-k2.5';         // /dtd bakeoff pick; override via KIMI_MODEL
const TEMPERATURE = 1;                                        // Moonshot REQUIRES temp=1 for the k2.x/k3 family
const MAX_TOKENS = Number(process.env.KIMI_MAX_TOKENS || 8000);

// kimi-k2.5 pay-as-you-go rates (per token), from cost-tracker/pricing.json
const RATE_IN = 5.5e-7;
const RATE_OUT = 2.2e-6;

// ── Moonshot chat helper ───────────────────────────────────────────────────
// Returns { text, usage, cost }. Handles the reasoning-model quirk where the
// visible answer lands in reasoning_content while content comes back empty
// (the exact failure that produced silent-empty Kimi votes in /dtd).
async function askKimi(messages) {
  if (!MOONSHOT_API_KEY) throw new Error('MOONSHOT_API_KEY is not set');

  const body = {
    model: MODEL,
    messages,
    temperature: TEMPERATURE,
    max_tokens: MAX_TOKENS,
  };

  const call = () => fetch(`${MOONSHOT_BASE}/chat/completions`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${MOONSHOT_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });

  let res = await call();
  if (res.status === 429) {                 // gentle single retry on rate-limit
    await new Promise(r => setTimeout(r, 10_000));
    res = await call();
  }
  if (res.status === 401) throw new Error('MOONSHOT_API_KEY invalid or expired');
  if (!res.ok) throw new Error(`Moonshot API error ${res.status}: ${await res.text()}`);

  const data = await res.json();
  const msg = data.choices?.[0]?.message || {};
  const text = (msg.content && msg.content.trim())
    || (msg.reasoning_content && msg.reasoning_content.trim())   // reasoning-model fallback
    || '[kimi returned an empty message]';

  const usage = data.usage || { prompt_tokens: 0, completion_tokens: 0 };
  const cost = usage.prompt_tokens * RATE_IN + usage.completion_tokens * RATE_OUT;
  return { text, usage, cost };
}

// One-line cost footer so every paid call surfaces its spend (Steve's rule).
function footer({ usage, cost }) {
  return `\n\n---\n_Kimi ${MODEL} · ${usage.prompt_tokens || 0} in / ${usage.completion_tokens || 0} out · ~$${cost.toFixed(4)}_`;
}

function ok(result) {
  return { content: [{ type: 'text', text: result.text + footer(result) }] };
}
function fail(err) {
  return { content: [{ type: 'text', text: `Kimi error: ${err.message}` }], isError: true };
}

// ── MCP server ───────────────────────────────────────────────────────────────
const server = new McpServer({ name: 'kimi-mcp', version: '1.0.0' });

server.tool(
  'ask_kimi',
  'Ask Moonshot Kimi (kimi-k2.5) a question and return its answer. Use for a second-opinion / independent reasoning lens, or to offload a self-contained analysis to a different model. Kimi has no access to your files — include any needed context in the prompt.',
  {
    prompt: z.string().describe('The question or task for Kimi. Self-contained — paste any needed context/code inline.'),
    system: z.string().optional().describe('Optional system instruction to steer Kimi (persona, format, constraints).'),
  },
  async ({ prompt, system }) => {
    try {
      const messages = [];
      if (system) messages.push({ role: 'system', content: system });
      messages.push({ role: 'user', content: prompt });
      return ok(await askKimi(messages));
    } catch (err) {
      return fail(err);
    }
  },
);

server.tool(
  'review_with_kimi',
  'Have Kimi (kimi-k2.5) review a code snippet or diff for bugs, security issues, edge cases, and quality — an independent adversarial reviewer lens. Paste the code directly (Kimi cannot read your filesystem).',
  {
    code: z.string().describe('The code, snippet, or unified diff to review. Paste it inline.'),
    focus: z.string().optional().describe('Optional aspect to prioritize (e.g. "security", "concurrency", "the error paths").'),
    context: z.string().optional().describe('Optional surrounding context — what the code does, language, constraints.'),
  },
  async ({ code, focus, context }) => {
    try {
      const focusNote = focus ? ` Focus especially on: ${focus}.` : '';
      const ctxNote = context ? `Context: ${context}\n\n` : '';
      const messages = [
        {
          role: 'system',
          content: `You are an expert, skeptical code reviewer. Find real bugs, security issues, race conditions, edge cases, and quality problems. Be specific and actionable — cite the line/construct and give the fix. If the code is genuinely fine, say so briefly rather than inventing nits.${focusNote}`,
        },
        { role: 'user', content: `${ctxNote}Review this code:\n\n\`\`\`\n${code}\n\`\`\`` },
      ];
      return ok(await askKimi(messages));
    } catch (err) {
      return fail(err);
    }
  },
);

const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`kimi-mcp up · model=${MODEL} · key=${MOONSHOT_API_KEY ? 'present' : 'MISSING'}`);