1
0
forked from baron/baron-sso

keto MCP 제작. ory-keto-migrate 오류 재발생 해결

This commit is contained in:
Lectom C Han
2026-01-28 14:36:34 +09:00
parent e573f4ca50
commit f33f417045
8 changed files with 456 additions and 5 deletions

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:lts
WORKDIR /app

12
mcp/keto-mcp/Dockerfile Normal file
View File

@@ -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"]

16
mcp/keto-mcp/package.json Normal file
View File

@@ -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"
}
}

328
mcp/keto-mcp/src/index.js Normal file
View File

@@ -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();

70
mcp/keto-mcp/src/runner.js Executable file
View File

@@ -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");
}

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:lts
WORKDIR /app