From f33f4170454a8828522b5edf262e5b0118950d42 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Wed, 28 Jan 2026 14:36:34 +0900 Subject: [PATCH] =?UTF-8?q?keto=20MCP=20=EC=A0=9C=EC=9E=91.=20ory-keto-mig?= =?UTF-8?q?rate=20=EC=98=A4=EB=A5=98=20=EC=9E=AC=EB=B0=9C=EC=83=9D=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +- compose.ory.yaml | 19 ++- mcp/hydra-mcp/Dockerfile | 2 +- mcp/keto-mcp/Dockerfile | 12 ++ mcp/keto-mcp/package.json | 16 ++ mcp/keto-mcp/src/index.js | 328 +++++++++++++++++++++++++++++++++++++ mcp/keto-mcp/src/runner.js | 70 ++++++++ mcp/kratos-mcp/Dockerfile | 2 +- 8 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 mcp/keto-mcp/Dockerfile create mode 100644 mcp/keto-mcp/package.json create mode 100644 mcp/keto-mcp/src/index.js create mode 100755 mcp/keto-mcp/src/runner.js diff --git a/README.md b/README.md index ce939770..bcf64e05 100644 --- a/README.md +++ b/README.md @@ -125,12 +125,12 @@ docker compose -f docker-compose.yaml up -d - **Hydra Public**: http://localhost:4444 - **Kratos UI**: http://localhost:4455 -### MCP 서버 (Hydra/Kratos) +### MCP 서버 (Hydra/Kratos/Keto) MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다. 프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요. ```bash -docker compose -f compose.ory.yaml --profile mcp up -d hydra-mcp-server kratos-mcp-server +docker compose -f compose.ory.yaml --profile mcp up -d hydra-mcp-server kratos-mcp-server keto-mcp-server ``` - MCP 서버는 stdio 기반이라 외부 포트를 열지 않습니다. @@ -152,6 +152,14 @@ args = ["-y", "/home/lectom/repos/baron-sso/mcp/hydra-mcp"] [mcp_servers.hydra-mcp.env] HYDRA_PUBLIC_URL = "http://localhost:4441" HYDRA_ADMIN_URL = "http://localhost:4445" + +[mcp_servers.keto-mcp] +command = "npx" +args = ["-y", "/home/lectom/repos/baron-sso/mcp/keto-mcp"] + +[mcp_servers.keto-mcp.env] +KETO_READ_URL = "http://localhost:4466" +KETO_WRITE_URL = "http://localhost:4467" ``` ### 로컬 개발 (Manual) diff --git a/compose.ory.yaml b/compose.ory.yaml index 2724810a..3baa589f 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -154,6 +154,23 @@ services: networks: - ory-net + keto-mcp-server: + build: + context: ./mcp/keto-mcp + container_name: mcp_ory_keto + profiles: + - mcp + stdin_open: true + tty: true + init: true + environment: + - KETO_READ_URL=http://keto:4466 + - KETO_WRITE_URL=http://keto:4467 + depends_on: + - keto + networks: + - ory-net + # --- Keto --- keto-migrate: image: oryd/keto:${KETO_VERSION:-v25.4.0} @@ -161,7 +178,7 @@ services: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20 volumes: - ./docker/ory/keto:/etc/config/keto - command: migrate up --yes + command: migrate up -c /etc/config/keto/keto.yml --yes depends_on: postgres_ory: condition: service_healthy diff --git a/mcp/hydra-mcp/Dockerfile b/mcp/hydra-mcp/Dockerfile index 3314add6..f046b2be 100644 --- a/mcp/hydra-mcp/Dockerfile +++ b/mcp/hydra-mcp/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine +FROM node:lts WORKDIR /app diff --git a/mcp/keto-mcp/Dockerfile b/mcp/keto-mcp/Dockerfile new file mode 100644 index 00000000..f046b2be --- /dev/null +++ b/mcp/keto-mcp/Dockerfile @@ -0,0 +1,12 @@ +FROM node:lts + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY src ./src + +ENV NODE_ENV=production + +CMD ["node", "./src/index.js"] diff --git a/mcp/keto-mcp/package.json b/mcp/keto-mcp/package.json new file mode 100644 index 00000000..b169601a --- /dev/null +++ b/mcp/keto-mcp/package.json @@ -0,0 +1,16 @@ +{ + "name": "mcp-ory-keto", + "version": "0.1.0", + "description": "MCP server for Ory Keto Read/Write APIs", + "type": "module", + "bin": { + "mcp-ory-keto": "./src/runner.js" + }, + "scripts": { + "start": "node ./src/index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.0", + "zod": "^3.25.0" + } +} diff --git a/mcp/keto-mcp/src/index.js b/mcp/keto-mcp/src/index.js new file mode 100644 index 00000000..c5d07573 --- /dev/null +++ b/mcp/keto-mcp/src/index.js @@ -0,0 +1,328 @@ +#!/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(); diff --git a/mcp/keto-mcp/src/runner.js b/mcp/keto-mcp/src/runner.js new file mode 100755 index 00000000..0e75a496 --- /dev/null +++ b/mcp/keto-mcp/src/runner.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const sdkVersion = "^1.25.0"; +const zodVersion = "^3.25.0"; +const cacheRoot = resolveCacheRoot(); +const sdkMarker = path.join(cacheRoot, "node_modules", "@modelcontextprotocol", "sdk", "package.json"); + +if (!existsSync(sdkMarker)) { + mkdirSync(cacheRoot, { recursive: true }); + + const installResult = spawnSync( + "npm", + [ + "install", + "--no-audit", + "--no-fund", + "--prefix", + cacheRoot, + `@modelcontextprotocol/sdk@${sdkVersion}`, + `zod@${zodVersion}`, + ], + { + encoding: "utf-8", + env: { + ...process.env, + npm_config_loglevel: "error", + }, + }, + ); + + if (installResult.stdout) { + process.stderr.write(installResult.stdout); + } + if (installResult.stderr) { + process.stderr.write(installResult.stderr); + } + + if (installResult.status !== 0) { + process.exit(installResult.status ?? 1); + } +} + +const env = { + ...process.env, + MCP_MODULES_DIR: cacheRoot, +}; + +const entryPath = fileURLToPath(new URL("./index.js", import.meta.url)); +const child = spawn(process.execPath, [entryPath], { + stdio: "inherit", + env, +}); + +child.on("exit", (code) => { + process.exit(code ?? 0); +}); + +function resolveCacheRoot() { + if (process.env.MCP_ORY_KETO_CACHE_DIR) { + return process.env.MCP_ORY_KETO_CACHE_DIR; + } + + const baseCache = process.env.XDG_CACHE_HOME ?? path.join(homedir(), ".cache"); + return path.join(baseCache, "mcp-ory-keto"); +} diff --git a/mcp/kratos-mcp/Dockerfile b/mcp/kratos-mcp/Dockerfile index 491af763..f9a555a5 100644 --- a/mcp/kratos-mcp/Dockerfile +++ b/mcp/kratos-mcp/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine +FROM node:lts WORKDIR /app