forked from baron/baron-sso
345 lines
8.4 KiB
JavaScript
345 lines
8.4 KiB
JavaScript
#!/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();
|