forked from baron/baron-sso
keto MCP 제작. ory-keto-migrate 오류 재발생 해결
This commit is contained in:
12
README.md
12
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:lts
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
12
mcp/keto-mcp/Dockerfile
Normal file
12
mcp/keto-mcp/Dockerfile
Normal 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
16
mcp/keto-mcp/package.json
Normal 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
328
mcp/keto-mcp/src/index.js
Normal 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
70
mcp/keto-mcp/src/runner.js
Executable 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");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:lts
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
Reference in New Issue
Block a user