Skip to main content
Glama

ChatGPT App with OAuth2 + MCP + Privy

by Jahnik
CODE_PATTERNS.mdโ€ข15.3 kB
# MCP2 - Essential Code Patterns This document contains copy-paste-ready patterns for common operations in mcp2. ## 1. Creating a New MCP Tool ### Step 1: Define Zod Schema (in tools.ts) ```typescript const MyToolSchema = z.object({ requiredParam: z.string().min(1, 'Required param is required'), optionalParam?: z.string().optional(), numberParam?: z.number().optional(), }); ``` ### Step 2: Register Tool in ListToolsRequestSchema Handler ```typescript server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ // ... existing tools ... { name: 'my-tool', description: 'What my tool does', inputSchema: { type: 'object', properties: { requiredParam: { type: 'string', description: 'Description of param', }, optionalParam: { type: 'string', description: 'Optional param', }, numberParam: { type: 'number', description: 'Number param', }, }, required: ['requiredParam'], }, annotations: { readOnlyHint: true, }, _meta: { 'openai/outputTemplate': 'ui://widget/my-tool.html', 'openai/toolInvocation/invoking': 'Processing...', 'openai/toolInvocation/invoked': 'Done', 'openai/widgetAccessible': true, 'openai/resultCanProduceWidget': true, }, }, ], }; }); ``` ### Step 3: Add Case in CallToolRequestSchema Handler ```typescript server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const { name, arguments: args } = request.params; const auth = (extra as any)?.auth; switch (name) { case 'my-tool': return await handleMyTool(args, auth); // ... other cases ... } }); ``` ### Step 4: Implement Handler Function ```typescript async function handleMyTool(args: any, auth: any) { // 1. Validate authentication if (!auth || !auth.userId) { return { content: [{ type: 'text', text: 'Authentication required.' }], isError: true, _meta: { 'mcp/www_authenticate': 'Bearer resource_metadata="..."' }, }; } // 2. Validate input with Zod const parseResult = MyToolSchema.safeParse(args); if (!parseResult.success) { return { content: [{ type: 'text', text: `Invalid input: ${parseResult.error.errors.map(e => e.message).join(', ')}`, }], isError: true, }; } const { requiredParam, optionalParam, numberParam } = parseResult.data; try { // 3. Exchange OAuth token for Privy token (if calling Protocol API) const privyToken = await exchangePrivyToken(auth.token); // 4. Call Protocol API const response = await fetch(`${config.intentExtraction.protocolApiUrl}/my-endpoint`, { method: 'POST', headers: { 'Authorization': `Bearer ${privyToken}` }, body: JSON.stringify({ requiredParam, optionalParam, numberParam }), signal: AbortSignal.timeout(60000), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Protocol API error: ${response.status} - ${errorText}`); } const data = await response.json(); // 5. Return structured response for widget return { content: [{ type: 'text', text: `Success: ${data.message}` }], structuredContent: { // Data that widget will receive in window.openai.toolOutput result: data.result, count: data.count, }, _meta: { 'openai/toolInvocation/invoked': `Processed ${data.count} items`, }, }; } catch (error) { console.error('Error in my-tool:', error); return { content: [{ type: 'text', text: `Failed: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, }; } } ``` --- ## 2. Token Exchange Pattern ```typescript async function exchangePrivyToken(oauthToken: string): Promise<string> { const tokenPreview = `${oauthToken.slice(0, 8)}...${oauthToken.slice(-8)}`; console.log(`[exchangePrivyToken] Exchanging OAuth token ${tokenPreview}`); const response = await fetch(`${config.server.baseUrl}/token/privy/access-token`, { method: 'POST', headers: { 'Authorization': `Bearer ${oauthToken}`, }, signal: AbortSignal.timeout(config.intentExtraction.privyTokenExchangeTimeoutMs), }); if (!response.ok) { const errorBody = await response.text(); console.error(`[exchangePrivyToken] Exchange failed: ${response.status}`, errorBody); throw new Error(`Failed to exchange token: ${response.status} ${errorBody}`); } const data = await response.json() as { privyAccessToken: string }; console.log(`[exchangePrivyToken] Successfully exchanged token`); return data.privyAccessToken; } ``` --- ## 3. Creating a New Widget ### Step 1: Create Component File ```typescript // src/widgets/src/MyTool/MyTool.tsx 'use client'; import { useOpenAi } from '../hooks/useOpenAi'; import { useWidgetState } from '../hooks/useWidgetState'; import './styles.css'; interface WidgetState { selectedId: string | null; loading: boolean; } export function MyTool() { const { toolOutput, theme, callTool } = useOpenAi(); const [widgetState, setWidgetState] = useWidgetState<WidgetState>(() => ({ selectedId: null, loading: false, })); // Extract data from toolOutput (ChatGPT may flatten structuredContent) const data = ( toolOutput?.structuredContent || toolOutput?.result?.structuredContent || toolOutput ); const items = data?.items || []; const handleItemSelect = (itemId: string) => { setWidgetState((prev) => ({ ...prev, selectedId: itemId, })); }; if (!data || items.length === 0) { return ( <div className={`my-tool theme-${theme}`}> <div className="empty-state">No items found</div> </div> ); } return ( <div className={`my-tool theme-${theme}`}> <div className="header"> <h2>My Tool</h2> </div> <div className="content"> {items.map((item: any) => ( <div key={item.id} className={`item ${widgetState.selectedId === item.id ? 'selected' : ''}`} onClick={() => handleItemSelect(item.id)} > <h3>{item.title}</h3> <p>{item.description}</p> </div> ))} </div> </div> ); } ``` ### Step 2: Create Entry Point ```typescript // src/widgets/src/MyTool/index.tsx import { createRoot } from 'react-dom/client'; import { MyTool } from './MyTool'; const root = createRoot(document.getElementById('root')!); root.render(<MyTool />); ``` ### Step 3: Create Styles ```css /* src/widgets/src/MyTool/styles.css */ .my-tool { padding: 1rem; font-family: system-ui, -apple-system, sans-serif; } .my-tool.theme-dark { background: #1e1e1e; color: #fff; } .my-tool.theme-light { background: #fff; color: #000; } .my-tool .header h2 { margin: 0 0 1rem 0; font-size: 1.25rem; } .my-tool .content { display: flex; flex-direction: column; gap: 0.75rem; } .my-tool .item { padding: 0.75rem; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; transition: all 0.2s; } .my-tool .item:hover { border-color: #0078d4; background: #f0f7ff; } .my-tool .item.selected { border-color: #0078d4; background: #e8f4f8; } .my-tool .item h3 { margin: 0 0 0.25rem 0; font-size: 0.95rem; } .my-tool .item p { margin: 0; font-size: 0.85rem; color: #666; } .my-tool .empty-state { padding: 2rem; text-align: center; color: #999; } ``` ### Step 4: Register Widget Resource (in resources.ts) ```typescript const widgets = [ // ... existing widgets ... { fileName: 'my-tool', uri: 'ui://widget/my-tool.html', name: 'MyTool Widget', description: 'My tool widget description', }, ]; // Also add to getWidgetMeta function: const metadataMap: Record<string, any> = { // ... existing ... 'ui://widget/my-tool.html': { 'openai/outputTemplate': 'ui://widget/my-tool.html', 'openai/toolInvocation/invoking': 'Loading...', 'openai/toolInvocation/invoked': 'Loaded', 'openai/widgetAccessible': true, 'openai/resultCanProduceWidget': true, }, }; ``` --- ## 4. Authentication Patterns ### Validating OAuth Token with Scope Requirements ```typescript // In handlers.ts or other route files import { validateToken } from '../middleware/auth.js'; mcpRouter.post('/', validateToken(['read', 'privy:token:exchange']), async (req, res) => { // At this point, req.auth is attached and has required scopes const { userId, scopes, token } = req.auth; // Use the token const privyToken = await exchangePrivyToken(token); // Continue... }); ``` ### Reading Auth Information in Tool Handlers ```typescript async function handleMyTool(args: any, auth: any) { // auth object contains: // { // token: string; // OAuth access token // decoded: jwt.JwtPayload; // Decoded JWT // userId: string; // Privy DID // scopes: string[]; // Token scopes // } // Check auth exists if (!auth || !auth.userId) { return { content: [...], isError: true }; } // Check specific scope if (!auth.scopes.includes('privy:token:exchange')) { return { content: [...], isError: true }; } // Use token const privyToken = await exchangePrivyToken(auth.token); } ``` --- ## 5. Zod Input Validation Patterns ### Simple Schema ```typescript const SimpleSchema = z.object({ text: z.string().min(1, 'Text is required'), count: z.number().min(1, 'Count must be at least 1'), optional: z.string().optional(), }); ``` ### Complex Schema with Nested Objects ```typescript const ComplexSchema = z.object({ userId: z.string().min(1, 'User ID is required'), filters: z.object({ type: z.string().optional(), status: z.enum(['active', 'inactive', 'archived']).optional(), dateRange: z.object({ start: z.string().optional(), end: z.string().optional(), }).optional(), }).optional(), pagination: z.object({ limit: z.number().min(1).max(100).optional(), offset: z.number().min(0).optional(), }).optional(), }); ``` ### Validation Pattern ```typescript const parseResult = MySchema.safeParse(args); if (!parseResult.success) { const errors = parseResult.error.errors .map(e => `${e.path.join('.')}: ${e.message}`) .join(', '); return { content: [{ type: 'text', text: `Invalid input: ${errors}` }], isError: true, }; } const validatedArgs = parseResult.data; // Use validatedArgs... ``` --- ## 6. Protocol API Call Patterns ### FormData POST (for upload-like operations) ```typescript const formData = new FormData(); formData.append('payload', JSON.stringify({ ...data })); const response = await fetch(`${config.intentExtraction.protocolApiUrl}/discover/new`, { method: 'POST', headers: { 'Authorization': `Bearer ${privyToken}` }, body: formData, signal: AbortSignal.timeout(60000), }); ``` ### JSON POST ```typescript const response = await fetch(`${config.intentExtraction.protocolApiUrl}/discover/connections`, { method: 'POST', headers: { 'Authorization': `Bearer ${privyToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ userId: privyUserId, filters: { type: 'direct', status: 'active' }, }), signal: AbortSignal.timeout(60000), }); ``` ### Response Handling ```typescript if (!response.ok) { const errorText = await response.text(); console.error(`Protocol API error: ${response.status}`, errorText); throw new Error(`Protocol API error: ${response.status} - ${errorText}`); } const data = await response.json() as ExpectedResponseType; ``` --- ## 7. Widget Data Flow Pattern ### In Tool Handler - Return Data ```typescript return { content: [ { type: 'text', text: `Found ${data.items.length} items` } ], structuredContent: { // This is what widget receives in window.openai.toolOutput.structuredContent items: data.items.map(item => ({ id: item.id, title: item.name, description: item.description, metadata: item.metadata, })), total: data.total, filters: data.appliedFilters, }, _meta: { 'openai/toolInvocation/invoked': `Found ${data.items.length} items`, }, }; ``` ### In Widget - Receive Data ```typescript export function MyWidget() { const toolOutput = useOpenAi(); // ChatGPT may flatten structuredContent to top level const data = ( toolOutput?.structuredContent || toolOutput?.result?.structuredContent || toolOutput ); const items = data?.items || []; // Render items... } ``` --- ## 8. Widget State Management Pattern ### Define State Type ```typescript interface MyWidgetState { selectedIds: string[]; filters: { type?: string; status?: string; }; loading: boolean; expandedId?: string; } ``` ### Use Hook ```typescript const [widgetState, setWidgetState] = useWidgetState<MyWidgetState>(() => ({ selectedIds: [], filters: {}, loading: false, })); ``` ### Update State ```typescript // Single update setWidgetState((prev) => ({ ...prev, selectedIds: [...prev.selectedIds, newId], })); // Conditional update setWidgetState((prev) => ({ ...prev, expandedId: prev.expandedId === id ? undefined : id, })); ``` ### Read State ```typescript const isSelected = widgetState.selectedIds.includes(item.id); const isExpanded = widgetState.expandedId === item.id; ``` --- ## 9. Error Handling Patterns ### Tool Error Response ```typescript return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, _meta: { // Optional: include error type for ChatGPT to understand 'mcp/error_code': 'OPERATION_FAILED', }, }; ``` ### Authentication Error Response ```typescript return { content: [{ type: 'text', text: 'Authentication required.', }], isError: true, _meta: { 'mcp/www_authenticate': 'Bearer resource_metadata="' + config.server.baseUrl + '/.well-known/oauth-protected-resource"', }, }; ``` ### Input Validation Error Response ```typescript return { content: [{ type: 'text', text: `Invalid input: ${errors.join(', ')}`, }], isError: true, }; ``` --- ## 10. Helpful Constants & Configs ### Timeout Values ```typescript // Token exchange config.intentExtraction.privyTokenExchangeTimeoutMs // default: 10000 (10s) // Protocol API calls config.intentExtraction.protocolApiTimeoutMs // default: 60000 (60s) // Character limits for input config.intentExtraction.instructionCharLimit // default: 2000 config.intentExtraction.sectionCharLimit // default: 5000 ``` ### OAuth Scopes ```typescript config.oauth.scopesSupported // ['read', 'write', 'profile', 'privy:token:exchange'] // For tools calling Protocol API: const requiredScopes = ['read', 'privy:token:exchange']; ``` ### Base URLs ```typescript config.server.baseUrl // e.g., 'http://localhost:3002' config.intentExtraction.protocolApiUrl // e.g., 'https://protocol.example.com' ```

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/Jahnik/mcp2'

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