@rblez/authly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Authly MCP Server
3
+ *
4
+ * Exposes Supabase operations as MCP tools so an AI agent
5
+ * (or the dashboard UI) can manage the user's Supabase project
6
+ * through authly — no direct Supabase credentials needed.
7
+ *
8
+ * Protocol: MCP Streamable HTTP at /mcp
9
+ * When authorized, an AI connecting to this endpoint gets:
10
+ * execute_sql, list_tables, describe_table, list_auth_users,
11
+ * list_roles, assign_role, revoke_role, get_user_roles,
12
+ * list_migrations, get_migration_sql, run_migration
13
+ */
14
+
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
17
+ import { z } from "zod";
18
+ import { getSupabaseClient, fetchUsers } from "../lib/supabase.js";
19
+ import { listRoles, assignRoleToUser, revokeRoleFromUser, getUserRoles } from "../generators/roles.js";
20
+ import { listMigrations, getMigration, migrations } from "../generators/migrations.js";
21
+
22
+ // ── MCP Server Instance ──────────────────────────────
23
+
24
+ export function createMcpServer() {
25
+ const server = new McpServer(
26
+ { name: "authly", version: "0.1.0" },
27
+ { capabilities: { tools: {} } }
28
+ );
29
+
30
+ // ── SQL ──────────────────────────────────────────
31
+ server.tool(
32
+ "execute_sql",
33
+ "Execute arbitrary SQL against the connected Supabase database. Use for queries too complex for other tools.",
34
+ { sql: z.string().describe("The SQL query to execute") },
35
+ async ({ sql }) => {
36
+ const { client, errors } = getSupabaseClient();
37
+ if (!client) return fail(`Not connected to Supabase: ${errors.join(", ")}`);
38
+ try {
39
+ const result = await execSql(sql);
40
+ return text(JSON.stringify(result, null, 2));
41
+ } catch (e) {
42
+ return text(`SQL error: ${e.message}`, true);
43
+ }
44
+ }
45
+ );
46
+
47
+ // ── Tables ───────────────────────────────────────
48
+ server.tool(
49
+ "list_tables",
50
+ "List all tables in the public schema of the connected Supabase database.",
51
+ {},
52
+ async () => {
53
+ const { client, errors } = getSupabaseClient();
54
+ if (!client) return fail(`Not connected: ${errors.join(", ")}`);
55
+ const { data, error } = await client
56
+ .from("information_schema.tables")
57
+ .select("table_schema, table_name")
58
+ .eq("table_schema", "public");
59
+ if (error) return text(error.message, true);
60
+ return text(data.map(t => ` ${t.table_schema}.${t.table_name}`).join("\n") || " (no tables)");
61
+ }
62
+ );
63
+
64
+ server.tool(
65
+ "describe_table",
66
+ "Show column definitions for a specific table.",
67
+ { table: z.string() },
68
+ async ({ table }) => {
69
+ const { client, errors } = getSupabaseClient();
70
+ if (!client) return fail(`Not connected: ${errors.join(", ")}`);
71
+ const { data, error } = await client
72
+ .from("information_schema.columns")
73
+ .select("column_name, data_type, is_nullable, column_default")
74
+ .eq("table_schema", "public")
75
+ .eq("table_name", table);
76
+ if (error) return text(error.message, true);
77
+ return text(data.map(c =>
78
+ ` ${c.column_name.padEnd(24)} ${c.data_type.padEnd(20)} ${c.is_nullable === "YES" ? "NULL" : "NOT NULL"}${c.column_default ? " DEFAULT " + c.column_default : ""}`
79
+ ).join("\n") || `Table '${table}' not found`);
80
+ }
81
+ );
82
+
83
+ // ── Auth users ───────────────────────────────────
84
+ server.tool(
85
+ "list_auth_users",
86
+ "List users registered via Supabase Auth. Returns id, email, role, created_at.",
87
+ { limit: z.number().default(50) },
88
+ async ({ limit }) => {
89
+ const { users, error } = await fetchUsers({ limit });
90
+ if (error) return text(error, true);
91
+ if (!users.length) return text("No users registered");
92
+ return text(users.map(u =>
93
+ ` ${u.id.slice(0, 8)}… ${(u.email || "—").padEnd(32)} role:${u.raw_user_meta_data?.role ?? "user"} created:${u.created_at?.slice(0, 10) ?? "?"}`
94
+ ).join("\n"));
95
+ }
96
+ );
97
+
98
+ // ── RBAC ─────────────────────────────────────────
99
+ server.tool(
100
+ "list_roles",
101
+ "List all defined authorization roles in the database.",
102
+ {},
103
+ async () => {
104
+ const { roles, error } = await listRoles();
105
+ if (error) return text(error, true);
106
+ if (!roles.length) return text("No roles defined. Run migration '001_create_roles_table' first.");
107
+ return text(roles.map(r => ` ${r.name.padEnd(12)} ${r.description || ""}`).join("\n"));
108
+ }
109
+ );
110
+
111
+ server.tool(
112
+ "assign_role_to_user",
113
+ "Grant a role to a user by their Supabase user ID.",
114
+ { userId: z.string(), role: z.string() },
115
+ async ({ userId, role }) => {
116
+ const { success, error } = await assignRoleToUser(userId, role);
117
+ if (!success) return text(error, true);
118
+ return text(`Assigned '${role}' to ${userId.slice(0, 8)}…`);
119
+ }
120
+ );
121
+
122
+ server.tool(
123
+ "revoke_role_from_user",
124
+ "Remove a role from a user.",
125
+ { userId: z.string(), role: z.string() },
126
+ async ({ userId, role }) => {
127
+ const { success, error } = await revokeRoleFromUser(userId, role);
128
+ if (!success) return text(error, true);
129
+ return text(`Revoked '${role}' from ${userId.slice(0, 8)}…`);
130
+ }
131
+ );
132
+
133
+ server.tool(
134
+ "get_user_roles",
135
+ "Get all roles assigned to a specific user.",
136
+ { userId: z.string() },
137
+ async ({ userId }) => {
138
+ const { roles, error } = await getUserRoles(userId);
139
+ if (error) return text(error, true);
140
+ return text(roles.length ? ` ${roles.join(", ")}` : " (no roles)");
141
+ }
142
+ );
143
+
144
+ // ── Migrations ───────────────────────────────────
145
+ server.tool(
146
+ "list_migrations",
147
+ "List available SQL migrations.",
148
+ {},
149
+ async () => {
150
+ return text(migrations.map(m => ` ${m.name} — ${m.description}`).join("\n"));
151
+ }
152
+ );
153
+
154
+ server.tool(
155
+ "get_migration_sql",
156
+ "View the SQL for a specific migration.",
157
+ { name: z.string() },
158
+ async ({ name }) => {
159
+ const sql = getMigration(name);
160
+ if (!sql) return text(`Migration '${name}' not found`, true);
161
+ return text("```sql\n" + sql.trim() + "\n```");
162
+ }
163
+ );
164
+
165
+ server.tool(
166
+ "run_migration",
167
+ "Apply a migration SQL to the connected Supabase. Destructive — use with care.",
168
+ { name: z.string() },
169
+ async ({ name }) => {
170
+ const { client, errors } = getSupabaseClient();
171
+ if (!client) return fail(`Not connected: ${errors.join(", ")}`);
172
+ const sql = getMigration(name);
173
+ if (!sql) return text(`Migration '${name}' not found`, true);
174
+ try {
175
+ const result = await execSql(sql);
176
+ return text(`Migration '${name}' applied. Result: ${JSON.stringify(result).slice(0, 200)}`);
177
+ } catch (e) {
178
+ return text(`Migration failed: ${e.message}`, true);
179
+ }
180
+ }
181
+ );
182
+
183
+ // ── Supabase connection info ─────────────────────
184
+ server.tool(
185
+ "connection_info",
186
+ "Show the current Supabase connection status and project URL.",
187
+ {},
188
+ async () => {
189
+ const url = process.env.SUPABASE_URL;
190
+ if (!url) return text("Not connected to Supabase. Run 'npx @rblez/authly init' or connect from the dashboard.");
191
+ return text(` Connected to: ${url}\n ANON_KEY: ${process.env.SUPABASE_ANON_KEY ? "set" : "missing"}\n SERVICE_ROLE: ${process.env.SUPABASE_SERVICE_ROLE_KEY ? "set" : "missing"}`);
192
+ }
193
+ );
194
+
195
+ return server;
196
+ }
197
+
198
+ // ── Mount on Hono ────────────────────────────────────
199
+
200
+ const sessionTransports = new Map();
201
+
202
+ /**
203
+ * Mount the MCP server onto a Hono app at /mcp.
204
+ * Uses Streamable HTTP transport so MCP clients
205
+ * (Claude Desktop, cursor, etc.) and the dashboard
206
+ * can both communicate with the tool server.
207
+ */
208
+ export function mountMcp(app) {
209
+ const mcpServer = createMcpServer();
210
+
211
+ app.all("/mcp", async (c) => {
212
+ // Each client session gets its own transport
213
+ const sessionId = c.req.header("Mcp-Session-Id");
214
+
215
+ let transport;
216
+ if (sessionId && sessionTransports.has(sessionId)) {
217
+ transport = sessionTransports.get(sessionId);
218
+ } else {
219
+ const supabaseOk = process.env.SUPABASE_URL && process.env.SUPABASE_ANON_KEY;
220
+ if (!supabaseOk) {
221
+ return c.json({
222
+ jsonrpc: "2.0",
223
+ error: { code: -32600, message: "Not connected to Supabase. Authorize from the dashboard first." },
224
+ id: null,
225
+ }, 400);
226
+ }
227
+
228
+ transport = new StreamableHTTPServerTransport({
229
+ sessionIdGenerator: () => crypto.randomUUID(),
230
+ onsessioninitialized: (sid) => sessionTransports.set(sid, transport),
231
+ });
232
+
233
+ transport.onclose = () => {
234
+ if (transport.sessionId) sessionTransports.delete(transport.sessionId);
235
+ };
236
+
237
+ await mcpServer.connect(transport);
238
+ }
239
+
240
+ await transport.handleRequest(c.req.raw, c.req.raw.signal);
241
+ });
242
+ }
243
+
244
+ // ── Helpers ──────────────────────────────────────────
245
+
246
+ function text(content, isError = false) {
247
+ return { content: [{ type: "text", text: content }], isError };
248
+ }
249
+ function fail(message) {
250
+ return text(message, true);
251
+ }
252
+
253
+ /**
254
+ * Execute raw SQL via PostgREST endpoint.
255
+ * Since supabase-js doesn't support raw SQL directly,
256
+ * we use the REST API with a stored procedure.
257
+ */
258
+ async function execSql(sql) {
259
+ const url = process.env.SUPABASE_URL;
260
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY;
261
+ if (!key) throw new Error("Supabase credentials not configured");
262
+
263
+ const res = await fetch(`${url}/rest/v1/`, {
264
+ method: "POST",
265
+ headers: {
266
+ apikey: key,
267
+ Authorization: `Bearer ${key}`,
268
+ "Content-Type": "application/json",
269
+ Prefer: "return=representation",
270
+ },
271
+ body: JSON.stringify({ _sql: sql }),
272
+ });
273
+
274
+ if (!res.ok) {
275
+ const body = await res.text();
276
+ throw new Error(`${res.status}: ${body.slice(0, 300)}`);
277
+ }
278
+
279
+ const text = await res.text();
280
+ try { return JSON.parse(text); } catch { return { raw: text }; }
281
+ }