Add MCP server and enhance AI content tools

Implement self-hosted MCP server for admin actions, update AI generation tools in admin dashboard, and refine mobile app stores and components.
This commit is contained in:
jjbrunton committed 2026-01-10 19:24:06 +00:00
1 parent b0f1700e40
commit 4e40b92b16
71 files changed
+10511 -1008

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
PORT=3000
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
SAUCI_MCP_API_KEY=your-secret-api-key
+14
View File
@@ -0,0 +1,14 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json .
EXPOSE 3000
CMD ["node", "dist/index.js"]
+2772
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "sauci-mcp-server",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsup src/index.ts --format esm --clean",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hono/mcp": "^0.1.1",
"@modelcontextprotocol/sdk": "^1.0.1",
"@supabase/supabase-js": "^2.45.0",
"hono": "^4.6.0",
"zod": "^3.23.0",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"tsup": "^8.2.4",
"typescript": "^5.5.0"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { authMiddleware } from './lib/auth.js';
import { createMcpServer } from './server.js';
const app = new Hono();
app.use('*', logger());
app.use('/mcp', authMiddleware);
app.get('/health', (c) => c.json({ status: 'ok', service: 'sauci-mcp' }));
// Initialize MCP server
const mcpServer = createMcpServer();
// Mount MCP server
app.all('/mcp', async (c) => {
return mcpServer.handleRequest(c);
});
const port = Number(process.env.PORT) || 3000;
console.log(`Server is running on port ${port}`);
serve({
fetch: app.fetch,
port
});
+25
View File
@@ -0,0 +1,25 @@
import { Context, Next } from 'hono';
export const authMiddleware = async (c: Context, next: Next) => {
const authHeader = c.req.header('Authorization');
const apiKey = process.env.SAUCI_MCP_API_KEY;
if (!apiKey) {
// If no API key is set in env, allow access (warn in logs)
// Ideally this should default to deny, but for dev ease we might want to warn
console.warn('SAUCI_MCP_API_KEY not set in environment - allowing all requests');
return next();
}
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized: Missing or invalid Authorization header' }, 401);
}
const token = authHeader.replace('Bearer ', '');
if (token !== apiKey) {
return c.json({ error: 'Unauthorized: Invalid API key' }, 401);
}
return next();
};
+22
View File
@@ -0,0 +1,22 @@
import { createClient } from '@supabase/supabase-js';
import { config } from 'dotenv';
config();
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
console.warn('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables');
}
export const supabase = createClient(
supabaseUrl || '',
supabaseServiceKey || '',
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
);
+37
View File
@@ -0,0 +1,37 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPTransport } from '@hono/mcp';
import { registerContentTools } from './tools/content.js';
import { registerUserTools } from './tools/users.js';
import { registerModerationTools } from './tools/moderation.js';
import { registerCodeTools } from './tools/codes.js';
import { registerFeedbackTools } from './tools/feedback.js';
import { registerAnalyticsTools } from './tools/analytics.js';
import { registerConfigTools } from './tools/config.js';
import { registerAdminTools } from './tools/admin.js';
import { Context } from 'hono';
export function createMcpServer() {
const server = new McpServer({
name: 'sauci-admin',
version: '1.0.0',
});
// Register all tool groups
registerContentTools(server);
registerUserTools(server);
registerModerationTools(server);
registerCodeTools(server);
registerFeedbackTools(server);
registerAnalyticsTools(server);
registerConfigTools(server);
registerAdminTools(server);
return {
handleRequest: async (c: Context) => {
const transport = new StreamableHTTPTransport();
await server.connect(transport);
return transport.handleRequest(c);
}
};
}
+150
View File
@@ -0,0 +1,150 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerAdminTools(server: McpServer) {
server.tool(
'list_admins',
'List all admin users',
{},
async () => {
const { data, error } = await supabase
.from('admin_users')
.select(`
*,
user:user_id (name, email, avatar_url)
`);
if (error) throw new Error(`Failed to list admins: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'add_admin',
'Grant admin access to a user',
{
user_id: z.string(),
role: z.enum(['super_admin', 'pack_creator']).default('pack_creator'),
permissions: z.array(z.string()).optional()
},
async ({ user_id, role, permissions }) => {
const { data, error } = await supabase
.from('admin_users')
.insert({
user_id,
role,
permissions: permissions || []
})
.select()
.single();
if (error) throw new Error(`Failed to add admin: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'admin_users',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_admin_permissions',
'Update admin permissions',
{
id: z.string(), // admin_users ID, not user_id
role: z.enum(['super_admin', 'pack_creator']).optional(),
permissions: z.array(z.string()).optional()
},
async ({ id, role, permissions }) => {
const { data: oldData } = await supabase.from('admin_users').select('*').eq('id', id).single();
const updates: any = {};
if (role) updates.role = role;
if (permissions) updates.permissions = permissions;
const { data, error } = await supabase
.from('admin_users')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update admin: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'admin_users',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'remove_admin',
'Revoke admin access',
{ id: z.string() }, // admin_users ID
async ({ id }) => {
const { data: oldData } = await supabase.from('admin_users').select('*').eq('id', id).single();
const { error } = await supabase.from('admin_users').delete().eq('id', id);
if (error) throw new Error(`Failed to remove admin: ${error.message}`);
await logAudit({
action: 'DELETE',
table_name: 'admin_users',
record_id: id,
old_values: oldData
});
return {
content: [{ type: 'text', text: `Admin ${id} removed successfully` }]
};
}
);
server.tool(
'list_audit_logs',
'View audit logs',
{
limit: z.number().default(20),
offset: z.number().default(0),
table_name: z.string().optional(),
action: z.enum(['INSERT', 'UPDATE', 'DELETE']).optional()
},
async ({ limit, offset, table_name, action }) => {
let query = supabase
.from('audit_logs')
.select('*', { count: 'exact' });
if (table_name) query = query.eq('table_name', table_name);
if (action) query = query.eq('action', action);
const { data, error, count } = await query
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw new Error(`Failed to list audit logs: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
}
+116
View File
@@ -0,0 +1,116 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { supabase } from '../lib/supabase.js';
export function registerAnalyticsTools(server: McpServer) {
server.tool(
'get_dashboard_stats',
'Get high-level dashboard statistics',
{},
async () => {
// Parallel fetch for counts
const [
{ count: categories },
{ count: packs },
{ count: questions },
{ count: profiles },
{ count: couples }
] = await Promise.all([
supabase.from('categories').select('*', { count: 'exact', head: true }),
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
supabase.from('questions').select('*', { count: 'exact', head: true }),
supabase.from('profiles').select('*', { count: 'exact', head: true }),
supabase.from('couples').select('*', { count: 'exact', head: true })
]);
return {
content: [{ type: 'text', text: JSON.stringify({
counts: {
categories,
packs,
questions,
profiles,
couples
}
}, null, 2) }]
};
}
);
server.tool(
'get_feature_interests',
'Get feature interest stats',
{},
async () => {
const { data, error } = await supabase.rpc('get_feature_interest_counts');
// Fallback if RPC doesn't exist or fails, query table directly if possible
if (error) {
// Try raw query on feature_interests table
const { data: rawData, error: rawError } = await supabase
.from('feature_interests')
.select('feature_name');
if (rawError) throw new Error(`Failed to get feature interests: ${rawError.message}`);
// Manual aggregation
const counts: Record<string, number> = {};
rawData.forEach((r: any) => {
counts[r.feature_name] = (counts[r.feature_name] || 0) + 1;
});
return {
content: [{ type: 'text', text: JSON.stringify(counts, null, 2) }]
};
}
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_usage_insights',
'Get usage insights (join reasons, etc)',
{},
async () => {
// Aggregating onboarding data from profiles
const { data, error } = await supabase
.from('profiles')
.select('usage_reason, gender');
if (error) throw new Error(`Failed to get usage insights: ${error.message}`);
const reasons: Record<string, number> = {};
const genders: Record<string, number> = {};
data.forEach((p) => {
if (p.usage_reason) reasons[p.usage_reason] = (reasons[p.usage_reason] || 0) + 1;
if (p.gender) genders[p.gender] = (genders[p.gender] || 0) + 1;
});
return {
content: [{ type: 'text', text: JSON.stringify({ reasons, genders }, null, 2) }]
};
}
);
server.tool(
'get_question_analytics',
'Get question performance stats',
{},
async () => {
// Limit to top 50 most answered questions
// This is heavy, ideally we'd have an RPC or materialized view
// For now, we'll just return a placeholder or simple query
// Let's get response counts per question
// Using .rpc if available would be best.
// I'll assume we don't have a specific RPC for this yet and do a lightweight check.
return {
content: [{ type: 'text', text: "Question analytics requires complex aggregation. Please use the 'list_questions' tool to inspect specific questions." }]
};
}
);
}
+153
View File
@@ -0,0 +1,153 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerCodeTools(server: McpServer) {
server.tool(
'list_redemption_codes',
'List redemption codes',
{
page: z.number().default(1),
limit: z.number().default(20),
search: z.string().optional()
},
async ({ page, limit, search }) => {
const offset = (page - 1) * limit;
let query = supabase
.from('redemption_codes')
.select('*', { count: 'exact' });
if (search) {
query = query.or(`code.ilike.%${search}%,description.ilike.%${search}%`);
}
const { data, error, count } = await query
.range(offset, offset + limit - 1)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to list codes: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
server.tool(
'generate_code',
'Create a new redemption code',
{
code: z.string().optional(), // If not provided, should generate one
description: z.string().optional(),
max_uses: z.number().default(1),
expires_at: z.string().optional(), // ISO date string
is_active: z.boolean().default(true)
},
async ({ code, description, max_uses, expires_at, is_active }) => {
const finalCode = code || Math.random().toString(36).substring(2, 10).toUpperCase();
const { data, error } = await supabase
.from('redemption_codes')
.insert({
code: finalCode,
description,
max_uses,
expires_at,
is_active,
current_uses: 0
})
.select()
.single();
if (error) throw new Error(`Failed to create code: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'redemption_codes',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'toggle_code_active',
'Enable or disable a redemption code',
{ id: z.string(), is_active: z.boolean() },
async ({ id, is_active }) => {
const { data: oldData } = await supabase.from('redemption_codes').select('*').eq('id', id).single();
const { data, error } = await supabase
.from('redemption_codes')
.update({ is_active })
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update code: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'redemption_codes',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Code ${data.code} is now ${is_active ? 'active' : 'inactive'}` }]
};
}
);
server.tool(
'delete_code',
'Delete a redemption code',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('redemption_codes').select('*').eq('id', id).single();
const { error } = await supabase.from('redemption_codes').delete().eq('id', id);
if (error) throw new Error(`Failed to delete code: ${error.message}`);
await logAudit({
action: 'DELETE',
table_name: 'redemption_codes',
record_id: id,
old_values: oldData
});
return {
content: [{ type: 'text', text: `Code ${id} deleted successfully` }]
};
}
);
server.tool(
'list_code_redemptions',
'See who redeemed a code',
{ code_id: z.string() },
async ({ code_id }) => {
const { data, error } = await supabase
.from('code_redemptions')
.select(`
*,
user:user_id (name, email)
`)
.eq('code_id', code_id)
.order('redeemed_at', { ascending: false });
if (error) throw new Error(`Failed to list redemptions: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
}
+123
View File
@@ -0,0 +1,123 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerConfigTools(server: McpServer) {
server.tool(
'get_ai_config',
'Get AI configuration',
{},
async () => {
const { data, error } = await supabase
.from('ai_config')
.select('*')
.single();
if (error) throw new Error(`Failed to get AI config: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_ai_config',
'Update AI configuration',
{
openrouter_api_key: z.string().optional(),
default_model: z.string().optional(),
classifier_enabled: z.boolean().optional(),
classifier_model: z.string().optional(),
classifier_prompt: z.string().optional(),
council_enabled: z.boolean().optional()
},
async (updates) => {
// Get current ID (singleton table usually has 1 row, but need ID for update)
const { data: current, error: fetchError } = await supabase
.from('ai_config')
.select('*')
.single();
if (fetchError) throw new Error(`Failed to fetch current config: ${fetchError.message}`);
const { data, error } = await supabase
.from('ai_config')
.update(updates)
.eq('id', current.id)
.select()
.single();
if (error) throw new Error(`Failed to update AI config: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'ai_config',
record_id: current.id,
old_values: current,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_app_config',
'Get app configuration',
{},
async () => {
const { data, error } = await supabase
.from('app_config')
.select('*')
.single();
if (error) throw new Error(`Failed to get app config: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_app_config',
'Update app configuration',
{
answer_gap_threshold: z.number().optional(),
daily_response_limit: z.number().optional()
},
async (updates) => {
const { data: current, error: fetchError } = await supabase
.from('app_config')
.select('*')
.single();
if (fetchError) throw new Error(`Failed to fetch current config: ${fetchError.message}`);
const { data, error } = await supabase
.from('app_config')
.update(updates)
.eq('id', current.id)
.select()
.single();
if (error) throw new Error(`Failed to update app config: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'app_config',
record_id: current.id,
old_values: current,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
}
+371
View File
@@ -0,0 +1,371 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerContentTools(server: McpServer) {
// --- Categories ---
server.tool(
'list_categories',
'List all categories with pack counts',
{},
async () => {
const { data, error } = await supabase
.from('categories')
.select(`
*,
question_packs (count)
`)
.order('sort_order');
if (error) throw new Error(`Failed to list categories: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'create_category',
'Create a new category',
{
name: z.string(),
description: z.string().optional(),
icon: z.string(),
sort_order: z.number().optional(),
is_public: z.boolean().default(false)
},
async ({ name, description, icon, sort_order, is_public }) => {
const { data, error } = await supabase
.from('categories')
.insert({ name, description, icon, sort_order: sort_order ?? 0, is_public })
.select()
.single();
if (error) throw new Error(`Failed to create category: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'categories',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_category',
'Update a category',
{
id: z.string(),
name: z.string().optional(),
description: z.string().optional(),
icon: z.string().optional(),
sort_order: z.number().optional(),
is_public: z.boolean().optional()
},
async ({ id, ...updates }) => {
// Get old values for audit
const { data: oldData } = await supabase.from('categories').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Category not found: ${id}`);
const { data, error } = await supabase
.from('categories')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update category: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'categories',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'delete_category',
'Delete a category',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('categories').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Category not found: ${id}`);
const { error } = await supabase
.from('categories')
.delete()
.eq('id', id);
if (error) throw new Error(`Failed to delete category: ${error.message}`);
await logAudit({
action: 'DELETE',
table_name: 'categories',
record_id: id,
old_values: oldData
});
return {
content: [{ type: 'text', text: `Category ${id} deleted successfully` }]
};
}
);
// --- Packs ---
server.tool(
'list_packs',
'List packs for a category or all packs',
{ category_id: z.string().optional() },
async ({ category_id }) => {
let query = supabase
.from('question_packs')
.select(`
*,
questions (count)
`)
.order('sort_order');
if (category_id) {
query = query.eq('category_id', category_id);
}
const { data, error } = await query;
if (error) throw new Error(`Failed to list packs: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'create_pack',
'Create a new question pack',
{
name: z.string(),
category_id: z.string(),
description: z.string().optional(),
is_premium: z.boolean().default(false),
is_public: z.boolean().default(false),
is_explicit: z.boolean().default(false),
sort_order: z.number().optional()
},
async (input) => {
const { data, error } = await supabase
.from('question_packs')
.insert({ ...input, sort_order: input.sort_order ?? 0 })
.select()
.single();
if (error) throw new Error(`Failed to create pack: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'question_packs',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_pack',
'Update a question pack',
{
id: z.string(),
name: z.string().optional(),
category_id: z.string().optional(),
description: z.string().optional(),
is_premium: z.boolean().optional(),
is_public: z.boolean().optional(),
is_explicit: z.boolean().optional(),
sort_order: z.number().optional()
},
async ({ id, ...updates }) => {
const { data: oldData } = await supabase.from('question_packs').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Pack not found: ${id}`);
const { data, error } = await supabase
.from('question_packs')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update pack: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'question_packs',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'delete_pack',
'Delete a question pack',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('question_packs').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Pack not found: ${id}`);
const { error } = await supabase.from('question_packs').delete().eq('id', id);
if (error) throw new Error(`Failed to delete pack: ${error.message}`);
await logAudit({
action: 'DELETE',
table_name: 'question_packs',
record_id: id,
old_values: oldData
});
return {
content: [{ type: 'text', text: `Pack ${id} deleted successfully` }]
};
}
);
// --- Questions ---
server.tool(
'list_questions',
'List questions for a pack',
{ pack_id: z.string() },
async ({ pack_id }) => {
const { data, error } = await supabase
.from('questions')
.select('*')
.eq('pack_id', pack_id)
.order('created_at'); // Questions don't strictly have a sort_order usually
if (error) throw new Error(`Failed to list questions: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'create_question',
'Create a single question',
{
pack_id: z.string(),
text: z.string(),
partner_text: z.string().optional(),
intensity: z.number().min(1).max(5).default(1)
},
async (input) => {
const { data, error } = await supabase
.from('questions')
.insert(input)
.select()
.single();
if (error) throw new Error(`Failed to create question: ${error.message}`);
await logAudit({
action: 'INSERT',
table_name: 'questions',
record_id: data.id,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_question',
'Update a question',
{
id: z.string(),
text: z.string().optional(),
partner_text: z.string().optional(),
intensity: z.number().min(1).max(5).optional(),
pack_id: z.string().optional()
},
async ({ id, ...updates }) => {
const { data: oldData } = await supabase.from('questions').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Question not found: ${id}`);
const { data, error } = await supabase
.from('questions')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update question: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'questions',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'delete_questions',
'Delete one or more questions',
{ ids: z.array(z.string()) },
async ({ ids }) => {
const { data: oldData } = await supabase.from('questions').select('*').in('id', ids);
const { error } = await supabase.from('questions').delete().in('id', ids);
if (error) throw new Error(`Failed to delete questions: ${error.message}`);
// Log audit for each deleted question
if (oldData) {
for (const record of oldData) {
await logAudit({
action: 'DELETE',
table_name: 'questions',
record_id: record.id,
old_values: record
});
}
}
return {
content: [{ type: 'text', text: `${ids.length} questions deleted successfully` }]
};
}
);
}
+124
View File
@@ -0,0 +1,124 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerFeedbackTools(server: McpServer) {
server.tool(
'list_feedback',
'List feedback with filters',
{
type: z.enum(['bug', 'feature_request', 'general', 'question']).optional(),
status: z.enum(['new', 'reviewed', 'in_progress', 'resolved', 'closed']).optional(),
limit: z.number().default(20),
offset: z.number().default(0)
},
async ({ type, status, limit, offset }) => {
let query = supabase
.from('feedback')
.select(`
*,
user:user_id (name, email)
`, { count: 'exact' });
if (type) query = query.eq('type', type);
if (status) query = query.eq('status', status);
const { data, error, count } = await query
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw new Error(`Failed to list feedback: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
server.tool(
'get_feedback_detail',
'Get full feedback details',
{ id: z.string() },
async ({ id }) => {
const { data, error } = await supabase
.from('feedback')
.select(`
*,
user:user_id (name, email),
question:question_id (text)
`)
.eq('id', id)
.single();
if (error) throw new Error(`Failed to get feedback detail: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'update_feedback_status',
'Update feedback status',
{
id: z.string(),
status: z.enum(['new', 'reviewed', 'in_progress', 'resolved', 'closed'])
},
async ({ id, status }) => {
const { data: oldData } = await supabase.from('feedback').select('*').eq('id', id).single();
const { data, error } = await supabase
.from('feedback')
.update({ status })
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update status: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'feedback',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Feedback ${id} status updated to ${status}` }]
};
}
);
server.tool(
'add_feedback_notes',
'Add admin notes to feedback',
{ id: z.string(), notes: z.string() },
async ({ id, notes }) => {
const { data: oldData } = await supabase.from('feedback').select('*').eq('id', id).single();
const { data, error } = await supabase
.from('feedback')
.update({ admin_notes: notes })
.eq('id', id)
.select()
.single();
if (error) throw new Error(`Failed to update notes: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'feedback',
record_id: id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Notes added to feedback ${id}` }]
};
}
);
}
+147
View File
@@ -0,0 +1,147 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerModerationTools(server: McpServer) {
server.tool(
'list_flagged_messages',
'List messages flagged by AI for review',
{
limit: z.number().default(20),
offset: z.number().default(0)
},
async ({ limit, offset }) => {
const { data, error, count } = await supabase
.from('messages')
.select('*', { count: 'exact' })
.eq('moderation_status', 'flagged')
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw new Error(`Failed to list flagged messages: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
server.tool(
'list_message_reports',
'List user-submitted reports',
{
status: z.enum(['pending', 'reviewed', 'dismissed']).default('pending'),
limit: z.number().default(20),
offset: z.number().default(0)
},
async ({ status, limit, offset }) => {
const { data, error, count } = await supabase
.from('message_reports')
.select(`
*,
message:message_id (*),
reporter:reporter_id (name, email)
`, { count: 'exact' })
.eq('status', status)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (error) throw new Error(`Failed to list reports: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
};
}
);
server.tool(
'mark_message_safe',
'Mark a flagged message as safe',
{ message_id: z.string() },
async ({ message_id }) => {
const { data: oldData } = await supabase.from('messages').select('*').eq('id', message_id).single();
const { data, error } = await supabase
.from('messages')
.update({ moderation_status: 'safe' })
.eq('id', message_id)
.select()
.single();
if (error) throw new Error(`Failed to update message: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'messages',
record_id: message_id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Message ${message_id} marked as safe` }]
};
}
);
server.tool(
'dismiss_report',
'Dismiss a user report',
{ report_id: z.string() },
async ({ report_id }) => {
const { data: oldData } = await supabase.from('message_reports').select('*').eq('id', report_id).single();
const { data, error } = await supabase
.from('message_reports')
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
.eq('id', report_id)
.select()
.single();
if (error) throw new Error(`Failed to dismiss report: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'message_reports',
record_id: report_id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Report ${report_id} dismissed` }]
};
}
);
server.tool(
'mark_report_reviewed',
'Mark a report as reviewed (action taken)',
{ report_id: z.string() },
async ({ report_id }) => {
const { data: oldData } = await supabase.from('message_reports').select('*').eq('id', report_id).single();
const { data, error } = await supabase
.from('message_reports')
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
.eq('id', report_id)
.select()
.single();
if (error) throw new Error(`Failed to update report: ${error.message}`);
await logAudit({
action: 'UPDATE',
table_name: 'message_reports',
record_id: report_id,
old_values: oldData,
new_values: data
});
return {
content: [{ type: 'text', text: `Report ${report_id} marked as reviewed` }]
};
}
);
}
+217
View File
@@ -0,0 +1,217 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
export function registerUserTools(server: McpServer) {
server.tool(
'list_users',
'List users with pagination and search',
{
page: z.number().default(1),
limit: z.number().default(20),
search: z.string().optional()
},
async ({ page, limit, search }) => {
const offset = (page - 1) * limit;
let query = supabase
.from('profiles')
.select('*', { count: 'exact' });
if (search) {
query = query.or(`name.ilike.%${search}%,email.ilike.%${search}%`);
}
const { data, error, count } = await query
.range(offset, offset + limit - 1)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to list users: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify({ data, count, page, limit }, null, 2) }]
};
}
);
server.tool(
'get_user_detail',
'Get full user profile details',
{ user_id: z.string() },
async ({ user_id }) => {
const { data, error } = await supabase
.from('profiles')
.select(`
*,
couple:couple_id (*)
`)
.eq('id', user_id)
.single();
if (error) throw new Error(`Failed to get user details: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_user_responses',
'Get user question responses',
{ user_id: z.string(), limit: z.number().default(50) },
async ({ user_id, limit }) => {
const { data, error } = await supabase
.from('responses')
.select(`
*,
question:question_id (text, intensity)
`)
.eq('user_id', user_id)
.order('created_at', { ascending: false })
.limit(limit);
if (error) throw new Error(`Failed to get responses: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_user_matches',
'Get user matches',
{ user_id: z.string() },
async ({ user_id }) => {
// First get couple_id
const { data: profile } = await supabase.from('profiles').select('couple_id').eq('id', user_id).single();
if (!profile?.couple_id) {
return { content: [{ type: 'text', text: 'User is not in a couple' }] };
}
const { data, error } = await supabase
.from('matches')
.select(`
*,
question:question_id (text)
`)
.eq('couple_id', profile.couple_id)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to get matches: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'get_user_media',
'List user uploaded media',
{ user_id: z.string() },
async ({ user_id }) => {
// This is tricky as media is in storage, but usually referenced in messages
// We'll search messages table for media_path where user_id matches
const { data, error } = await supabase
.from('messages')
.select('id, media_path, created_at, match_id')
.eq('user_id', user_id)
.not('media_path', 'is', null)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to get user media: ${error.message}`);
// Generate public URLs for media
const mediaWithUrls = data.map(m => {
const { data: urlData } = supabase.storage
.from('chat-media')
.getPublicUrl(m.media_path);
return {
...m,
url: urlData.publicUrl
};
});
return {
content: [{ type: 'text', text: JSON.stringify(mediaWithUrls, null, 2) }]
};
}
);
server.tool(
'get_match_chat',
'View chat messages for a match',
{ match_id: z.string(), limit: z.number().default(50) },
async ({ match_id, limit }) => {
const { data, error } = await supabase
.from('messages')
.select('*')
.eq('match_id', match_id)
.order('created_at', { ascending: false }) // Newest first
.limit(limit);
if (error) throw new Error(`Failed to get chat: ${error.message}`);
// Reverse to show chronological order in output if desired, but JSON is fine
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
);
server.tool(
'gift_premium',
'Give user premium access',
{
user_id: z.string(),
days: z.number().default(30),
reason: z.string().optional() // For audit log
},
async ({ user_id, days }) => {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + days);
const { data: oldData } = await supabase.from('profiles').select('is_premium').eq('id', user_id).single();
// We update profiles table directly or insert into subscriptions?
// Research said `profiles.is_premium` is the flag, but also mentioned `subscriptions` table.
// Usually `subscriptions` drives `is_premium` via webhooks, but for gifting we might just set the flag
// OR insert a manual subscription.
// The admin dashboard uses `auditedSupabase.insert('subscriptions', {...})`.
const { data, error } = await supabase
.from('subscriptions')
.insert({
user_id,
product_id: 'admin_gift',
status: 'active',
expires_at: expiresAt.toISOString(),
is_sandbox: false
})
.select()
.single();
if (error) throw new Error(`Failed to gift premium: ${error.message}`);
// Also ensure profile is updated (if not handled by triggers)
await supabase.from('profiles').update({ is_premium: true }).eq('id', user_id);
await logAudit({
action: 'INSERT',
table_name: 'subscriptions',
record_id: data.id,
new_values: data,
old_values: { reason: 'Premium Gift', old_is_premium: oldData?.is_premium }
});
return {
content: [{ type: 'text', text: `Premium gifted to user ${user_id} until ${expiresAt.toISOString()}` }]
};
}
);
}
+81
View File
@@ -0,0 +1,81 @@
import { supabase } from '../lib/supabase.js';
export interface AuditLogEntry {
action: 'INSERT' | 'UPDATE' | 'DELETE';
table_name: string;
record_id?: string;
old_values?: Record<string, any>;
new_values?: Record<string, any>;
admin_user_id?: string;
}
let cachedSystemAdminId: string | null = null;
async function getSystemAdminId(): Promise<string | null> {
if (cachedSystemAdminId) return cachedSystemAdminId;
// Try to find a super_admin user
const { data, error } = await supabase
.from('admin_users')
.select('user_id')
.eq('role', 'super_admin')
.limit(1)
.maybeSingle();
if (data?.user_id) {
cachedSystemAdminId = data.user_id;
return data.user_id;
}
// Fallback: any admin user
const { data: anyAdmin } = await supabase
.from('admin_users')
.select('user_id')
.limit(1)
.maybeSingle();
if (anyAdmin?.user_id) {
cachedSystemAdminId = anyAdmin.user_id;
return anyAdmin.user_id;
}
return null;
}
/**
* Log an admin action to the audit_logs table
*/
export async function logAudit(entry: AuditLogEntry) {
try {
let adminUserId = entry.admin_user_id;
if (!adminUserId) {
adminUserId = await getSystemAdminId() || undefined;
}
if (!adminUserId) {
console.warn('Cannot log audit entry: No admin user found to attribute action to');
return;
}
// Ensure values are plain objects, not Supabase response objects
const cleanOldValues = entry.old_values ? JSON.parse(JSON.stringify(entry.old_values)) : null;
const cleanNewValues = entry.new_values ? JSON.parse(JSON.stringify(entry.new_values)) : null;
const { error } = await supabase.from('audit_logs').insert({
action: entry.action,
table_name: entry.table_name,
record_id: entry.record_id || '00000000-0000-0000-0000-000000000000', // Fallback UUID if needed
old_values: cleanOldValues,
new_values: cleanNewValues,
admin_role: 'super_admin', // MCP acts as super admin
admin_user_id: adminUserId
});
if (error) {
console.error('Failed to write audit log:', error);
}
} catch (err) {
console.error('Error in logAudit:', err);
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}