#!/usr/bin/env node import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; const modulesBase = process.env.MCP_MODULES_DIR; const requireFromModules = createRequire( modulesBase ? path.join(modulesBase, "package.json") : import.meta.url, ); const mcpModule = await import( resolveModule("@modelcontextprotocol/sdk/server/mcp.js") ); const stdioModule = await import( resolveModule("@modelcontextprotocol/sdk/server/stdio.js") ); const zodModule = await import(resolveModule("zod")); const { McpServer } = mcpModule; const { StdioServerTransport } = stdioModule; const { z } = zodModule; const ketoReadUrl = process.env.KETO_READ_URL ?? "http://127.0.0.1:4466"; const ketoWriteUrl = process.env.KETO_WRITE_URL ?? "http://127.0.0.1:4467"; const readApiToken = process.env.KETO_READ_API_TOKEN; const writeApiToken = process.env.KETO_WRITE_API_TOKEN; const timeoutMs = Number.parseInt( process.env.KETO_HTTP_TIMEOUT_MS ?? "15000", 10, ); class HttpError extends Error { constructor(message, status, body, url) { super(message); this.name = "HttpError"; this.status = status; this.body = body; this.url = url; } } function resolveModule(specifier) { const resolvedPath = requireFromModules.resolve(specifier); return pathToFileURL(resolvedPath).href; } function buildUrl(base, pathValue, query) { const url = new URL(pathValue, base); if (query) { for (const [key, value] of Object.entries(query)) { if (value === undefined || value === null || value === "") { continue; } url.searchParams.set(key, String(value)); } } return url.toString(); } function encodeBody(body) { if (body === undefined) { return { payload: undefined, contentType: undefined }; } if (typeof body === "string") { return { payload: body, contentType: undefined }; } return { payload: JSON.stringify(body), contentType: "application/json" }; } async function requestJson(url, { method = "GET", headers, body } = {}, token) { const controller = new AbortController(); const timeoutId = Number.isFinite(timeoutMs) ? setTimeout(() => controller.abort(), timeoutMs) : null; const { payload, contentType } = encodeBody(body); const requestHeaders = { accept: "application/json", ...headers, }; if (contentType && !requestHeaders["content-type"]) { requestHeaders["content-type"] = contentType; } if (token) { requestHeaders.authorization = `Bearer ${token}`; } try { const response = await fetch(url, { method, headers: requestHeaders, body: payload, signal: controller.signal, }); const contentTypeHeader = response.headers.get("content-type") ?? ""; const text = await response.text(); const data = text ? contentTypeHeader.includes("application/json") ? safeJsonParse(text) : text : null; if (!response.ok) { throw new HttpError( `HTTP ${response.status} ${response.statusText}`, response.status, data, url, ); } return { status: response.status, data, }; } finally { if (timeoutId) { clearTimeout(timeoutId); } } } function safeJsonParse(text) { try { return JSON.parse(text); } catch { return text; } } function formatToolResult(payload) { return { content: [ { type: "text", text: JSON.stringify(payload, null, 2), }, ], }; } function formatErrorResult(error) { if (error instanceof HttpError) { return formatToolResult({ error: { message: error.message, status: error.status, url: error.url, body: error.body, }, }); } return formatToolResult({ error: { message: error instanceof Error ? error.message : String(error), }, }); } const HealthInputSchema = z.object({ service: z.enum(["read", "write"]).optional(), probe: z.enum(["alive", "ready"]).optional(), }); const RelationTupleQuerySchema = z.object({ namespace: z.string().min(1).optional(), object: z.string().min(1).optional(), relation: z.string().min(1).optional(), subject_id: z.string().min(1).optional(), subject_set_namespace: z.string().min(1).optional(), subject_set_object: z.string().min(1).optional(), subject_set_relation: z.string().min(1).optional(), page_size: z.number().int().positive().max(500).optional(), page_token: z.string().min(1).optional(), }); const RelationTupleBodySchema = z.object({ body: z.record(z.unknown()), }); const WriteRelationTuplesSchema = z.object({ method: z.enum(["PATCH", "PUT"]).optional(), body: z.record(z.unknown()), }); async function main() { const server = new McpServer({ name: "mcp-ory-keto", version: "0.1.0", }); server.tool( "keto_health", "Check Keto health using /health/alive or /health/ready on read/write ports.", HealthInputSchema.shape, async (input) => { const service = input.service ?? "read"; const probe = input.probe ?? "ready"; const base = service === "write" ? ketoWriteUrl : ketoReadUrl; const url = buildUrl(base, `/health/${probe}`); try { const result = await requestJson( url, {}, service === "write" ? writeApiToken : readApiToken, ); return formatToolResult({ service, probe, status: result.status, data: result.data, }); } catch (error) { return formatErrorResult(error); } }, ); server.tool( "keto_list_relation_tuples", "List relation tuples using Keto Read API.", RelationTupleQuerySchema.shape, async (input) => { const url = buildUrl(ketoReadUrl, "/relation-tuples", input); try { const result = await requestJson(url, {}, readApiToken); return formatToolResult({ status: result.status, data: result.data, }); } catch (error) { return formatErrorResult(error); } }, ); server.tool( "keto_check_relation_tuple", "Check relation tuples using Keto Read API.", RelationTupleBodySchema.shape, async (input) => { const url = buildUrl(ketoReadUrl, "/relation-tuples/check"); try { const result = await requestJson( url, { method: "POST", body: input.body, }, readApiToken, ); return formatToolResult({ status: result.status, data: result.data, }); } catch (error) { return formatErrorResult(error); } }, ); server.tool( "keto_expand", "Expand relation tuples using Keto Read API.", RelationTupleBodySchema.shape, async (input) => { const url = buildUrl(ketoReadUrl, "/relation-tuples/expand"); try { const result = await requestJson( url, { method: "POST", body: input.body, }, readApiToken, ); return formatToolResult({ status: result.status, data: result.data, }); } catch (error) { return formatErrorResult(error); } }, ); server.tool( "keto_write_relation_tuples", "Write relation tuples using Keto Write API.", WriteRelationTuplesSchema.shape, async (input) => { const method = input.method ?? "PATCH"; const url = buildUrl(ketoWriteUrl, "/relation-tuples"); try { const result = await requestJson( url, { method, body: input.body, }, writeApiToken, ); return formatToolResult({ status: result.status, data: result.data, }); } catch (error) { return formatErrorResult(error); } }, ); server.tool( "keto_delete_relation_tuples", "Delete relation tuples using Keto Write API.", RelationTupleQuerySchema.shape, async (input) => { const url = buildUrl(ketoWriteUrl, "/relation-tuples", input); try { const result = await requestJson( url, { method: "DELETE", }, writeApiToken, ); return formatToolResult({ status: result.status, data: result.data, }); } catch (error) { return formatErrorResult(error); } }, ); const transport = new StdioServerTransport(); await server.connect(transport); } await main();