Skip to main content
Glama
generator.ts•7.15 kB
import { z } from 'zod'; import type { ToolHandler } from './tool-types.js'; import { REST } from '@discordjs/rest'; import { request } from 'undici'; import type { Policy } from './policy.js'; import { DiscordClient } from './discord.js'; type CatalogEntry = { name: string; aliases?: string[]; method: 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'; path: string; // e.g., /channels/:channel_id/messages description: string; pack?: 'CORE'|'ADMIN'|'MEDIA'|'COMMUNITY'|'DEVTOOLS'; confirm?: boolean; schema: any; // JSON Schema subset }; function jsonSchemaToZod(schema: any): z.ZodTypeAny { const t = schema.type; if (t === 'string') return z.string(); if (t === 'integer' || t === 'number') { let base = z.number(); if (schema.type === 'integer') base = z.number().int(); if (schema.minimum !== undefined) base = base.min(schema.minimum); if (schema.maximum !== undefined) base = base.max(schema.maximum); return base; } if (t === 'boolean') return z.boolean(); if (t === 'array') { const items = schema.items ? jsonSchemaToZod(schema.items) : z.any(); return z.array(items); } if (t === 'object') { const props = schema.properties || {}; const shape: Record<string, z.ZodTypeAny> = {}; for (const [k,v] of Object.entries(props)) shape[k] = jsonSchemaToZod(v); const base = z.object(shape); const req = schema.required || []; return base.superRefine((data, ctx)=>{ for (const r of req) if (data[r] === undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Missing required field: ${r}` }); }); } return z.any(); } function replacePathParams(path: string, params: Record<string,string>){ let out = path; for (const [k,v] of Object.entries(params)) { // Use simple string replacement instead of regex for better performance const placeholder = `:${k}`; const encoded = encodeURIComponent(String(v)); // Replace all occurrences while (out.includes(placeholder)) { out = out.replace(placeholder, encoded); } } return out; } function pickParamsForPath(path: string): string[] { const re = /:([a-zA-Z_][a-zA-Z0-9_]*)/g; const ids: string[] = []; let m; while ((m = re.exec(path))) ids.push(m[1]); return ids; } export type GeneratedTool = { handler: ToolHandler, entry: CatalogEntry }; export function generateTools(catalog: CatalogEntry[], dc: DiscordClient, policy: Policy, options: { packsEnabled: Set<string>, defaultAllowedMentions: any }): GeneratedTool[] { const rest = dc.getRest(); const tools: GeneratedTool[] = []; for (const entry of catalog) { const pack = entry.pack ?? 'CORE'; if (!options.packsEnabled.has(pack)) continue; // Build schema: path params + the schema's properties const pathParams = pickParamsForPath(entry.path); const baseSchema = entry.schema ?? { type: 'object', properties: {} }; // Create a single z.object with all properties merged const allProperties: Record<string, z.ZodTypeAny> = {}; // Add path parameters as required strings for (const param of pathParams) { allProperties[param] = z.string(); } // Add body schema properties if (baseSchema.type === 'object' && baseSchema.properties) { const requiredFields = new Set(baseSchema.required || []); for (const [key, value] of Object.entries(baseSchema.properties)) { let zodSchema = jsonSchemaToZod(value); // Make field optional if it's not in the required array if (!requiredFields.has(key)) { zodSchema = zodSchema.optional(); } allProperties[key] = zodSchema; } } const inputSchema = z.object(allProperties); const name = entry.name; const confirmDefault = Boolean(entry.confirm); const desc = `${entry.description}${entry.aliases?.length ? ` Aliases: ${entry.aliases.join(', ')}` : ''}${confirmDefault ? ' (confirm=true by default)' : ''}`; const handler: ToolHandler = { name, description: desc, inputSchema: inputSchema, async *handler({ input }) { const data = input; // Allow-lists (best-effort): block if channel_id/guild_id present and not allowed if (data.channel_id && !policy.allowChannel(String(data.channel_id))) throw new Error('Channel not allowed by policy'); if (data.guild_id && !policy.allowGuild(String(data.guild_id))) throw new Error('Guild not allowed by policy'); // Preview gate const confirm = ('confirm' in data) ? Boolean(data.confirm) : confirmDefault; if (confirm) { const preview = { method: entry.method, path: entry.path, input: data }; yield { content: [{ type: 'text', text: `Preview:\n${JSON.stringify(preview, null, 2)}\n(Resend with {confirm:false} to execute)` }] }; return; } // Build path and separate body vs path/query (simple heuristic: if field is in path, it's a path param; some GETs support query subset explicit in schema - we just pass everything else as query for GET, body otherwise) const params: Record<string,string> = {}; for (const p of pathParams) if (data[p] !== undefined) params[p] = String(data[p]); const apiPath = replacePathParams(entry.path, params); const restOptions: any = {}; // Remove path params and confirm from payload const payload: any = { ...data }; for (const p of pathParams) delete payload[p]; delete payload['confirm']; if (entry.method === 'GET' || entry.method === 'DELETE') { if (Object.keys(payload).length) restOptions.query = payload; } else { // Special-case allowed_mentions default for message posts if (name.startsWith('discord_post_message') || name === 'discord_reply' || apiPath.includes('/messages')) { // Validate allowed_mentions if provided by user if (payload.allowed_mentions && typeof payload.allowed_mentions === 'object') { const am = payload.allowed_mentions; // Ensure parse array contains only valid values if (am.parse && Array.isArray(am.parse)) { const validParse = ['users', 'roles', 'everyone']; am.parse = am.parse.filter((p: string) => validParse.includes(p)); } // Ensure arrays are actually arrays of strings if (am.users && !Array.isArray(am.users)) delete am.users; if (am.roles && !Array.isArray(am.roles)) delete am.roles; } payload.allowed_mentions = payload.allowed_mentions ?? options.defaultAllowedMentions; } restOptions.body = payload; } const route = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; const verb = entry.method.toLowerCase() as 'get' | 'post' | 'patch' | 'put' | 'delete'; const result = await rest[verb](route as `/${string}`, restOptions) as any; yield { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } }; tools.push({ handler, entry }); } return tools; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/GustyCube/discord-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server