0488cdda72
Build tinqs-git / build (push) Has started running
- Proxy: add bot.tinqs.com route alongside legacy bot.arikigame.com - Bot: add /api/v1/ai/* rewrite alias for inference proxy (Cursor endpoint) - Auth: update Gitea URL defaults from git.arikigame.com to tinqs.com - UI: update all landing page, team-tool, callback URLs to tinqs.com - Libs: update gitea.ts, design.ts, docs-search.ts, handoffs.ts, mcp-handler.ts, image-gen-context.ts to tinqs.com API base - Config: add tinqs-ai provider entry in deeptinqs providers.json - Tests: update smoke test default URL to bot.tinqs.com All endpoints work on both domains during transition. Old bot.arikigame.com stays in proxy routes for backwards compat. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
668 lines
24 KiB
TypeScript
668 lines
24 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
}
|
|
|
|
interface JsonRpcResponse {
|
|
jsonrpc: "2.0";
|
|
id: string | number;
|
|
result?: unknown;
|
|
error?: { code: number; message: string; data?: unknown };
|
|
}
|
|
|
|
// --- Resource definitions ---
|
|
|
|
const IDENTITY_FILES: Record<string, { repo: string; path: string; cache: number }> = {
|
|
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<string> {
|
|
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<string> {
|
|
return fetchFile("tinqs-ltd/docs", `.cursor/glossary/glossary-${user}.md`, 300);
|
|
}
|
|
|
|
async function handleGetHandoff(repo?: string): Promise<string> {
|
|
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<string> {
|
|
return fetchFile(repo, path, 120);
|
|
}
|
|
|
|
async function handleListSkills(): Promise<string> {
|
|
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<string> {
|
|
return fetchFile("tinqs-ltd/docs", `.cursor/skills/${name}/SKILL.md`, 300);
|
|
}
|
|
|
|
async function handleSearchDocs(query: string, repo?: string, limit?: number): Promise<string> {
|
|
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<string> {
|
|
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<string, unknown>
|
|
): Promise<string> {
|
|
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<string, unknown>
|
|
): Promise<string> {
|
|
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<string> {
|
|
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: <facts found, comma-separated>
|
|
MISSING: <facts not found, or "none">
|
|
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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
// 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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string, unknown>
|
|
): 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<string, unknown> | undefined
|
|
);
|
|
break;
|
|
case "gh_api":
|
|
result = await handleGhApi(
|
|
args.method as string,
|
|
args.path as string,
|
|
args.body as Record<string, unknown> | 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<JsonRpcResponse> {
|
|
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<string, unknown> }).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 },
|
|
};
|
|
}
|
|
}
|