/** * MCP JSON-RPC 2.0 handler for bot.arikigame. * * Implements Streamable HTTP transport (POST /api/mcp). * Resources: tinqs://identity/*, tinqs://glossary/* * Tools: get_identity, get_glossary, get_handoff, get_repo_file, search_docs, * list_skills, get_skill, gitea_api, gh_api, * list_active_meetings, get_live_transcript, query_meeting */ import { fetchFile, listDir, searchCode, listRepos } from "./gitea"; import { listGatewayPages, getGatewayPage } from "./gateway"; import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"; import { listActiveMeetings, getMostRecentSessionId, getTranscript } from "./store"; import { getLastNMinutes, formatTranscript } from "./transcript"; import { callTacoClaude } from "./claude"; // --- Types --- interface JsonRpcRequest { jsonrpc: "2.0"; id: string | number; method: string; params?: Record; } interface JsonRpcResponse { jsonrpc: "2.0"; id: string | number; result?: unknown; error?: { code: number; message: string; data?: unknown }; } // --- Resource definitions --- const IDENTITY_FILES: Record = { soul: { repo: "tinqs-ltd/docs", path: "ai/SOUL.md", cache: 300 }, company: { repo: "tinqs-ltd/docs", path: "ai/company.md", cache: 300 }, siblings: { repo: "tinqs-ltd/docs", path: "ai/siblings.md", cache: 300 }, urls: { repo: "tinqs-ltd/docs", path: "ai/urls.md", cache: 300 }, handoff: { repo: "tinqs-ltd/docs", path: "ai/handoff.md", cache: 60 }, gateway: { repo: "tinqs-ltd/docs", path: "ai/gateway.md", cache: 300 }, }; // --- MCP Protocol --- const TOOLS = [ { name: "get_identity", description: "Returns combined hub identity: SOUL + company + siblings + urls. Call this first on session start.", inputSchema: { type: "object" as const, properties: {}, required: [] as string[] }, }, { name: "get_glossary", description: "Returns speech-to-text glossary corrections for a specific user.", inputSchema: { type: "object" as const, properties: { user: { type: "string", description: "User name (e.g. 'ozan', 'ozlem', 'jeremy')" } }, required: ["user"], }, }, { name: "get_handoff", description: "Returns pending handoff tasks, optionally filtered by repo.", inputSchema: { type: "object" as const, properties: { repo: { type: "string", description: "Optional: filter tasks mentioning this repo" } }, required: [] as string[], }, }, { name: "get_repo_file", description: "Read any file from any repo on Git Studio.", inputSchema: { type: "object" as const, properties: { repo: { type: "string", description: "Full repo path, e.g. 'tinqs-ltd/docs'" }, path: { type: "string", description: "File path within repo, e.g. 'ai/SOUL.md'" }, }, required: ["repo", "path"], }, }, { name: "list_skills", description: "List all shared skills available from the docs repo.", inputSchema: { type: "object" as const, properties: {}, required: [] as string[] }, }, { name: "get_skill", description: "Get full SKILL.md content for a specific shared skill.", inputSchema: { type: "object" as const, properties: { name: { type: "string", description: "Skill directory name" } }, required: ["name"], }, }, { name: "search_docs", description: "Search across all Tinqs repos on Git Studio. Returns matching repos and file paths.", inputSchema: { type: "object" as const, properties: { query: { type: "string", description: "Search query" }, repo: { type: "string", description: "Optional: limit search to a specific repo (e.g. 'tinqs-ltd/isleborn')" }, limit: { type: "number", description: "Max results (default 20)" }, }, required: ["query"], }, }, { name: "list_all_repos", description: "List all repos on Git Studio with descriptions. Helps agents discover available repos.", inputSchema: { type: "object" as const, properties: {}, required: [] as string[] }, }, { name: "gitea_api", description: "Proxy any Gitea API call to Git Studio.", inputSchema: { type: "object" as const, properties: { method: { type: "string", enum: ["GET", "POST", "PUT", "DELETE", "PATCH"], description: "HTTP method" }, path: { type: "string", description: "API path after /api/v1, e.g. '/repos/tinqs-ltd/docs/issues'" }, body: { type: "object", description: "Optional request body for POST/PUT/PATCH" }, }, required: ["method", "path"], }, }, { name: "gh_api", description: "Proxy any GitHub API call.", inputSchema: { type: "object" as const, properties: { method: { type: "string", enum: ["GET", "POST", "PUT", "DELETE", "PATCH"], description: "HTTP method" }, path: { type: "string", description: "API path, e.g. '/repos/tinqs-ltd/website/issues'" }, body: { type: "object", description: "Optional request body for POST/PUT/PATCH" }, }, required: ["method", "path"], }, }, { name: "eval_agent", description: "Grade an agent's answer against a rubric using Bedrock LLM-as-judge. Fetches rubrics from tinqs-ltd/agent-eval repo. Returns a score 1-5 with reasoning.", inputSchema: { type: "object" as const, properties: { rubric_id: { type: "string", description: "Rubric ID, e.g. 'docs/q1-melih'. Use list_rubrics to see all available." }, agent_answer: { type: "string", description: "The agent's answer text to grade." }, }, required: ["rubric_id", "agent_answer"], }, }, { name: "list_rubrics", description: "List all available eval rubrics from the agent-eval repo.", inputSchema: { type: "object" as const, properties: {}, required: [] as string[] }, }, { name: "run_eval", description: "Run all docs rubrics against the MCP's own get_identity output. Self-test: calls get_identity, feeds it to Bedrock as the 'agent answer', scores each rubric. Returns full scorecard.", inputSchema: { type: "object" as const, properties: {}, required: [] as string[] }, }, { name: "list_gateway_pages", description: "List all gateway HTML/MD pages across all Tinqs repos. Each repo's gateway/ folder is aggregated.", inputSchema: { type: "object" as const, properties: {}, required: [] as string[] }, }, { name: "get_gateway_page", description: "Fetch a gateway page by slug. Returns the full HTML/MD content with parsed frontmatter.", inputSchema: { type: "object" as const, properties: { slug: { type: "string", description: "Page slug, e.g. 'docs--onboarding'. Use list_gateway_pages to see all slugs." }, }, required: ["slug"], }, }, { name: "list_active_meetings", description: "List currently active meeting sessions being recorded by team members.", inputSchema: { type: "object" as const, properties: {}, required: [] as string[] }, }, { name: "get_live_transcript", description: "Get the live rolling transcript from an active meeting session. Returns speaker-labeled, timestamped text.", inputSchema: { type: "object" as const, properties: { session_id: { type: "string", description: "Meeting session ID. If omitted, uses the most recent active session." }, }, required: [] as string[], }, }, { name: "query_meeting", description: "Ask a question about an ongoing meeting and get an AI-powered answer based on the live transcript.", inputSchema: { type: "object" as const, properties: { question: { type: "string", description: "Question to answer from the meeting transcript." }, session_id: { type: "string", description: "Meeting session ID. If omitted, uses the most recent active session." }, }, required: ["question"], }, }, ]; const RESOURCES = [ { uri: "tinqs://identity/soul", name: "Hub SOUL", description: "Tinqs shared identity", mimeType: "text/markdown" }, { uri: "tinqs://identity/company", name: "Company", description: "Team roster, machines, values", mimeType: "text/markdown" }, { uri: "tinqs://identity/siblings", name: "Siblings", description: "All repos and agents", mimeType: "text/markdown" }, { uri: "tinqs://identity/urls", name: "URLs", description: "Service URLs", mimeType: "text/markdown" }, { uri: "tinqs://identity/handoff", name: "Handoff", description: "Cross-machine task queue", mimeType: "text/markdown" }, { uri: "tinqs://identity/gateway", name: "Gateway", description: "Gateway capabilities", mimeType: "text/markdown" }, ]; // --- Tool handlers --- async function handleGetIdentity(): Promise { const parts = await Promise.all([ fetchFile("tinqs-ltd/docs", "ai/SOUL.md", 300), fetchFile("tinqs-ltd/docs", "ai/company.md", 300), fetchFile("tinqs-ltd/docs", "ai/siblings.md", 300), fetchFile("tinqs-ltd/docs", "ai/urls.md", 300), ]); return parts.join("\n\n---\n\n"); } async function handleGetGlossary(user: string): Promise { return fetchFile("tinqs-ltd/docs", `.cursor/glossary/glossary-${user}.md`, 300); } async function handleGetHandoff(repo?: string): Promise { const content = await fetchFile("tinqs-ltd/docs", "ai/handoff.md", 60); if (!repo) return content; // Filter to lines mentioning the repo const lines = content.split("\n"); const filtered = lines.filter( (l) => l.toLowerCase().includes(repo.toLowerCase()) || l.startsWith("#") || l.startsWith("---") ); return filtered.join("\n"); } async function handleGetRepoFile(repo: string, path: string): Promise { return fetchFile(repo, path, 120); } async function handleListSkills(): Promise { const entries = await listDir("tinqs-ltd/docs", ".cursor/skills"); const dirs = entries.filter((e) => e.type === "dir"); const skills = dirs.map((d) => `- ${d.name}`).join("\n"); return `# Shared Skills\n\n${skills}`; } async function handleGetSkill(name: string): Promise { return fetchFile("tinqs-ltd/docs", `.cursor/skills/${name}/SKILL.md`, 300); } async function handleSearchDocs(query: string, repo?: string, limit?: number): Promise { const results = await searchCode(query, { repo, limit: limit ?? 20 }); if (results.length === 0) return "No results found."; const lines = results.map((r) => { if (r.path) return `- **${r.repo}** / \`${r.path}\`${r.content ? " — " + r.content : ""}`; return `- **${r.repo}**${r.content ? " — " + r.content : ""}`; }); return `# Search: "${query}"\n\n${lines.join("\n")}`; } async function handleListAllRepos(): Promise { const repos = await listRepos(); const lines = repos.map((r) => `- **${r.full_name}**${r.private ? " (private)" : ""} — ${r.description || "no description"}` ); return `# Repos on Git Studio\n\n${lines.join("\n")}`; } async function handleGiteaApi( method: string, path: string, body?: Record ): Promise { const token = process.env.GITEA_TOKEN?.trim(); if (!token) throw new Error("GITEA_TOKEN not set"); const url = `https://tinqs.com/api/v1${path}`; const opts: RequestInit = { method, headers: { Authorization: `token ${token}`, "Content-Type": "application/json", }, signal: AbortSignal.timeout(15000), }; if (body && ["POST", "PUT", "PATCH"].includes(method)) { opts.body = JSON.stringify(body); } const r = await fetch(url, opts); const text = await r.text(); if (!r.ok) throw new Error(`Gitea ${r.status}: ${text.slice(0, 500)}`); return text; } async function handleGhApi( method: string, path: string, body?: Record ): Promise { const token = process.env.GITHUB_TOKEN?.trim(); if (!token) throw new Error("GITHUB_TOKEN not set"); const url = `https://api.github.com${path}`; const opts: RequestInit = { method, headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "Content-Type": "application/json", }, signal: AbortSignal.timeout(15000), }; if (body && ["POST", "PUT", "PATCH"].includes(method)) { opts.body = JSON.stringify(body); } const r = await fetch(url, opts); const text = await r.text(); if (!r.ok) throw new Error(`GitHub ${r.status}: ${text.slice(0, 500)}`); return text; } // --- Bedrock judge --- async function bedrockJudge(rubricYaml: string, agentAnswer: string): Promise { const region = process.env.AWS_REGION ?? "eu-west-1"; const model = process.env.BEDROCK_JUDGE_MODEL ?? "mistral.mistral-large-2402-v1:0"; const client = new BedrockRuntimeClient({ region }); const prompt = `You are an LLM-as-judge. You grade whether an AI agent's context data contains the facts needed to answer a question. RUBRIC (question + expected facts + scoring criteria): ${rubricYaml} AGENT'S CONTEXT DATA (this is what the agent received from an MCP call — search it carefully for each golden fact): ${agentAnswer.slice(0, 12000)} TASK: Search the context data above for each golden fact listed in the rubric. A fact counts as present if the information is there, even if worded differently. Then score 1-5 per the rubric. Respond in exactly this format: SCORE: <1-5> FOUND: MISSING: REASONING: <1 sentence>`; const body = JSON.stringify({ messages: [ { role: "user", content: prompt }, ], max_tokens: 400, temperature: 0.0, }); const command = new InvokeModelCommand({ modelId: model, contentType: "application/json", accept: "application/json", body: new TextEncoder().encode(body), }); const response = await client.send(command); const result = JSON.parse(new TextDecoder().decode(response.body)); const text = result?.choices?.[0]?.message?.content; return text?.trim() ?? "(No response from Bedrock)"; } async function handleListRubrics(): Promise { const dirs = await listDir("tinqs-ltd/agent-eval", "rubrics"); const results: string[] = ["# Eval Rubrics\n"]; for (const d of dirs) { if (d.type !== "dir") continue; const files = await listDir("tinqs-ltd/agent-eval", `rubrics/${d.name}`); for (const f of files) { if (f.name.endsWith(".yaml")) { results.push(`- ${d.name}/${f.name.replace(".yaml", "")}`); } } } return results.join("\n"); } async function handleEvalAgent(rubricId: string, agentAnswer: string): Promise { const rubricYaml = await fetchFile("tinqs-ltd/agent-eval", `rubrics/${rubricId}.yaml`, 300); const judgment = await bedrockJudge(rubricYaml, agentAnswer); return `# Eval: ${rubricId}\n\n${judgment}`; } async function handleRunEval(): Promise { // Fetch MCP identity data (what an agent gets on session start) const identity = await handleGetIdentity(); const handoff = await handleGetHandoff(); const allData = identity + "\n\n---\n\n" + handoff; // Fetch all docs rubrics const rubricFiles = await listDir("tinqs-ltd/agent-eval", "rubrics/docs"); const results: string[] = ["# MCP Self-Eval — Bedrock Judge\n"]; let totalScore = 0; let count = 0; for (const f of rubricFiles) { if (!f.name.endsWith(".yaml")) continue; const rubricId = `docs/${f.name.replace(".yaml", "")}`; try { const rubricYaml = await fetchFile("tinqs-ltd/agent-eval", `rubrics/${rubricId}.yaml`, 300); const judgment = await bedrockJudge(rubricYaml, allData); // Parse score from judgment const scoreMatch = judgment.match(/SCORE:\s*(\d)/); const score = scoreMatch ? parseInt(scoreMatch[1]) : 0; totalScore += score; count++; results.push(`## ${rubricId} — ${score}/5\n${judgment}\n`); } catch (e) { const msg = e instanceof Error ? e.message : String(e); results.push(`## ${rubricId} — ERROR\n${msg}\n`); count++; } } results.push(`---\n**TOTAL: ${totalScore}/${count * 5} | AVG: ${(totalScore / count).toFixed(1)}/5 | ${count} rubrics**`); return results.join("\n"); } // --- Meeting tool handlers --- const MEETING_TRANSCRIPT_WINDOW_MINUTES = 30; async function handleListActiveMeetings(): Promise { const meetings = await listActiveMeetings(); if (meetings.length === 0) return "No active meetings."; const lines = meetings.map( (m) => `- **${m.session_id}** — ${m.user}@${m.machine}, started ${m.started_at}, ${m.transcript_count} chunks` ); return `# Active Meetings (${meetings.length})\n\n${lines.join("\n")}`; } async function resolveSessionId(sessionId?: string): Promise { if (sessionId) return sessionId; const mostRecent = await getMostRecentSessionId(); if (!mostRecent) throw new Error("No active meeting sessions found."); return mostRecent; } async function handleGetLiveTranscript(sessionId?: string): Promise { const resolved = await resolveSessionId(sessionId); const allChunks = await getTranscript(resolved); const recent = getLastNMinutes(allChunks, MEETING_TRANSCRIPT_WINDOW_MINUTES); const formatted = formatTranscript(recent); return `# Live Transcript — ${resolved}\n\n${formatted}`; } async function handleQueryMeeting(question: string, sessionId?: string): Promise { const resolved = await resolveSessionId(sessionId); const allChunks = await getTranscript(resolved); const recent = getLastNMinutes(allChunks, MEETING_TRANSCRIPT_WINDOW_MINUTES); const transcript = formatTranscript(recent); if (recent.length === 0) { return `No transcript data available for session ${resolved}.`; } const answer = await callTacoClaude({ liveTranscriptBlock: transcript, docsBlock: "", question, source: "chat", }); return `# Meeting Q&A — ${resolved}\n\n**Q:** ${question}\n\n**A:** ${answer}`; } // --- Main handler --- async function handleToolCall( name: string, args: Record ): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { try { let result: string; switch (name) { case "get_identity": result = await handleGetIdentity(); break; case "get_glossary": result = await handleGetGlossary(args.user as string); break; case "get_handoff": result = await handleGetHandoff(args.repo as string | undefined); break; case "get_repo_file": result = await handleGetRepoFile(args.repo as string, args.path as string); break; case "search_docs": result = await handleSearchDocs(args.query as string, args.repo as string | undefined, args.limit as number | undefined); break; case "list_all_repos": result = await handleListAllRepos(); break; case "list_skills": result = await handleListSkills(); break; case "get_skill": result = await handleGetSkill(args.name as string); break; case "gitea_api": result = await handleGiteaApi( args.method as string, args.path as string, args.body as Record | undefined ); break; case "gh_api": result = await handleGhApi( args.method as string, args.path as string, args.body as Record | undefined ); break; case "eval_agent": result = await handleEvalAgent(args.rubric_id as string, args.agent_answer as string); break; case "list_rubrics": result = await handleListRubrics(); break; case "run_eval": result = await handleRunEval(); break; case "list_gateway_pages": { const pages = await listGatewayPages(); const lines = pages.map((p) => `- **${p.repoKey}** / \`${p.filename}\` — slug: \`${p.slug}\`` ); result = `# Gateway Pages (${pages.length})\n\n${lines.join("\n")}`; break; } case "get_gateway_page": { const gp = await getGatewayPage(args.slug as string); if (!gp) { result = "Page not found"; break; } const meta = [ gp.page.title && `**${gp.page.title}**`, gp.page.author && `Author: ${gp.page.author}`, gp.page.date && `Date: ${gp.page.date}`, gp.page.source && `Source: ${gp.page.source}`, gp.page.description, ].filter(Boolean).join(" | "); result = `${meta}\n\n---\n\n${gp.content}`; break; } case "list_active_meetings": result = await handleListActiveMeetings(); break; case "get_live_transcript": result = await handleGetLiveTranscript(args.session_id as string | undefined); break; case "query_meeting": result = await handleQueryMeeting(args.question as string, args.session_id as string | undefined); break; default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true }; } return { content: [{ type: "text", text: result }] }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true }; } } async function handleReadResource(uri: string): Promise<{ contents: Array<{ uri: string; mimeType: string; text: string }> }> { // Parse URI: tinqs://identity/soul → identity file const match = uri.match(/^tinqs:\/\/(\w+)\/(.+)$/); if (!match) throw new Error(`Unknown resource URI: ${uri}`); const [, category, name] = match; if (category === "identity") { const file = IDENTITY_FILES[name]; if (!file) throw new Error(`Unknown identity resource: ${name}`); const text = await fetchFile(file.repo, file.path, file.cache); return { contents: [{ uri, mimeType: "text/markdown", text }] }; } if (category === "glossary") { const text = await fetchFile("tinqs-ltd/docs", `.cursor/glossary/glossary-${name}.md`, 300); return { contents: [{ uri, mimeType: "text/markdown", text }] }; } throw new Error(`Unknown resource category: ${category}`); } // --- JSON-RPC dispatch --- export async function handleMcpRequest(req: JsonRpcRequest): Promise { const { id, method, params } = req; try { switch (method) { case "initialize": return { jsonrpc: "2.0", id, result: { protocolVersion: "2025-03-26", capabilities: { tools: { listChanged: false }, resources: { subscribe: false, listChanged: false }, }, serverInfo: { name: "bot-arikigame", version: "0.1.0", }, }, }; case "notifications/initialized": return { jsonrpc: "2.0", id, result: {} }; case "tools/list": return { jsonrpc: "2.0", id, result: { tools: TOOLS } }; case "tools/call": { const toolName = (params as { name: string }).name; const toolArgs = ((params as { arguments?: Record }).arguments) || {}; const result = await handleToolCall(toolName, toolArgs); return { jsonrpc: "2.0", id, result }; } case "resources/list": return { jsonrpc: "2.0", id, result: { resources: RESOURCES } }; case "resources/read": { const uri = (params as { uri: string }).uri; const result = await handleReadResource(uri); return { jsonrpc: "2.0", id, result }; } case "ping": return { jsonrpc: "2.0", id, result: {} }; default: return { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` }, }; } } catch (e) { const msg = e instanceof Error ? e.message : String(e); return { jsonrpc: "2.0", id, error: { code: -32000, message: msg }, }; } }