# Directory Structure ``` ├── .env.example ├── .gitignore ├── build │ ├── api │ │ ├── getBeerInfo.js │ │ ├── getBeerSearch.js │ │ └── getBreweryInfo.js │ ├── config.js │ ├── constants.js │ ├── index.js │ ├── libs │ │ ├── format.js │ │ └── guards.js │ └── types │ └── untappedApi.js ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── api │ │ ├── getBeerInfo.ts │ │ ├── getBeerSearch.ts │ │ └── getBreweryInfo.ts │ ├── constants.ts │ ├── index.ts │ ├── libs │ │ ├── format.ts │ │ └── guards.ts │ └── types │ └── untappedApi.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- ``` 1 | # Untapped API 2 | # Rename this file to .env after you add your values 3 | UNTAPPED_API_CLIENT_ID= 4 | UNTAPPED_API_CLIENT_SECRET= ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # idea 133 | .idea ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # untapped-mcp 2 | 3 | A Untapped MCP server to be used with claude. 4 | 5 | ## Setup 6 | 7 | ### Get API Key 8 | 9 | ### Usage with Claude Desktop 10 | 11 | Add the following to your `claude_desktop_config.json`: 12 | 13 | ```json 14 | { 15 | "mcpServers": { 16 | "Untappd": { 17 | "command": "node", 18 | "args": ["/Users/user/projects/untapped-mcp/build/index.js"], 19 | "env": { 20 | "UNTAPPED_API_CLIENT_ID": "<YOUR_CLIENT_ID>", 21 | "UNTAPPED_API_CLIENT_SECRET": "<YOUR_CLIENT_SECRET>" 22 | } 23 | } 24 | } 25 | } 26 | ``` 27 | ``` -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Untapped 2 | export const UNTAPPED_API_BASE = "https://api.untappd.com/v4/"; 3 | export const UNTAPPED_API_SEARCH = "search/beer"; 4 | export const UNTAPPED_API_INFO = "beer/info"; 5 | export const UNTAPPED_API_BREWERY_INFO = "/brewery/info/"; 6 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | ``` -------------------------------------------------------------------------------- /src/libs/guards.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { UntappdApiErrorResponse } from "../types/untappedApi.js"; 2 | 3 | export function isUntappdApiError( 4 | value: unknown, 5 | ): value is UntappdApiErrorResponse { 6 | return ( 7 | typeof value === "object" && 8 | value !== null && 9 | "meta" in value && 10 | typeof (value as any).meta === "object" && 11 | "code" in (value as any).meta && 12 | "error_detail" in (value as any).meta && 13 | "error_type" in (value as any).meta 14 | ); 15 | } 16 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "untapped-mcp", 3 | "version": "1.0.0", 4 | "description": "A Untapped MCP server to be used with claude.", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/etoxin/untapped-mcp.git" 9 | }, 10 | "keywords": [], 11 | "author": "Adam Lusted", 12 | "license": "ISC", 13 | "bugs": { 14 | "url": "https://github.com/etoxin/untapped-mcp/issues" 15 | }, 16 | "homepage": "https://github.com/etoxin/untapped-mcp#readme", 17 | "type": "module", 18 | "bin": { 19 | "untapped": "./build/index.js" 20 | }, 21 | "files": [ 22 | "build" 23 | ], 24 | "scripts": { 25 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", 26 | "test": "echo \"Error: no test specified\" && exit 1" 27 | }, 28 | "dependencies": { 29 | "@modelcontextprotocol/sdk": "^1.6.1", 30 | "axios": "^1.8.2", 31 | "dotenv": "^16.4.7", 32 | "zod": "^3.24.2" 33 | }, 34 | "devDependencies": { 35 | "@types/node": "^22.13.9", 36 | "typescript": "^5.8.2" 37 | } 38 | } 39 | ``` -------------------------------------------------------------------------------- /src/api/getBeerInfo.ts: -------------------------------------------------------------------------------- ```typescript 1 | import axios from "axios"; 2 | import { UNTAPPED_API_BASE, UNTAPPED_API_INFO } from "../constants.js"; 3 | import { isUntappdApiError } from "../libs/guards.js"; 4 | import { UntappdBeerInfoResult } from "../types/untappedApi.js"; 5 | import { config } from "../index.js"; 6 | 7 | export async function GetBeerInfo(bid: string) { 8 | try { 9 | const response = await axios.get<UntappdBeerInfoResult>( 10 | `${UNTAPPED_API_BASE}${UNTAPPED_API_INFO}/${bid}`, 11 | { 12 | params: { 13 | client_id: config.untappd.clientId, 14 | client_secret: config.untappd.clientSecret, 15 | }, 16 | }, 17 | ); 18 | 19 | return response.data; 20 | } catch (e: unknown) { 21 | if (axios.isAxiosError(e) && e.response) { 22 | throw new Error(`HTTP error! status: ${e.response.status}`); 23 | } 24 | if (e instanceof Error) { 25 | return e.message; 26 | } 27 | if (isUntappdApiError(e)) { 28 | return e.meta.error_detail; 29 | } 30 | return "An unknown error occurred"; 31 | } 32 | } 33 | ``` -------------------------------------------------------------------------------- /src/api/getBreweryInfo.ts: -------------------------------------------------------------------------------- ```typescript 1 | import axios from "axios"; 2 | import { 3 | UNTAPPED_API_BASE, 4 | UNTAPPED_API_BREWERY_INFO, 5 | UNTAPPED_API_INFO, 6 | } from "../constants.js"; 7 | import { isUntappdApiError } from "../libs/guards.js"; 8 | import { UntappdBreweryInfoResult } from "../types/untappedApi.js"; 9 | import { config } from "../index.js"; 10 | 11 | export async function GetBreweryInfo(breweryId: string) { 12 | try { 13 | const response = await axios.get<UntappdBreweryInfoResult>( 14 | `${UNTAPPED_API_BASE}${UNTAPPED_API_BREWERY_INFO}/${breweryId}`, 15 | { 16 | params: { 17 | client_id: config.untappd.clientId, 18 | client_secret: config.untappd.clientSecret, 19 | }, 20 | }, 21 | ); 22 | 23 | return response.data; 24 | } catch (e: unknown) { 25 | if (axios.isAxiosError(e) && e.response) { 26 | throw new Error(`HTTP error! status: ${e.response.status}`); 27 | } 28 | if (e instanceof Error) { 29 | return e.message; 30 | } 31 | if (isUntappdApiError(e)) { 32 | return e.meta.error_detail; 33 | } 34 | return "An unknown error occurred"; 35 | } 36 | } 37 | ``` -------------------------------------------------------------------------------- /src/api/getBeerSearch.ts: -------------------------------------------------------------------------------- ```typescript 1 | import axios from "axios"; 2 | import { UNTAPPED_API_SEARCH, UNTAPPED_API_BASE } from "../constants.js"; 3 | import { isUntappdApiError } from "../libs/guards.js"; 4 | import { UntappdBeerSearchResult } from "../types/untappedApi.js"; 5 | import { config } from "../index.js"; 6 | 7 | export async function getBeerSearch(query: string) { 8 | try { 9 | const response = await axios.get<UntappdBeerSearchResult>( 10 | `${UNTAPPED_API_BASE}${UNTAPPED_API_SEARCH}`, 11 | { 12 | params: { 13 | q: query, 14 | client_id: config.untappd.clientId, 15 | client_secret: config.untappd.clientSecret, 16 | }, 17 | }, 18 | ); 19 | 20 | return response.data; 21 | } catch (e: unknown) { 22 | if (axios.isAxiosError(e) && e.response) { 23 | throw new Error( 24 | `HTTP error! status: ${e.response.status}: ${JSON.stringify(e)}`, 25 | ); 26 | } 27 | if (e instanceof Error) { 28 | return e.message; 29 | } 30 | if (isUntappdApiError(e)) { 31 | return e.meta.error_detail; 32 | } 33 | return "An unknown error occurred"; 34 | } 35 | } 36 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 | import { z } from "zod"; 4 | import { getBeerSearch } from "./api/getBeerSearch.js"; 5 | import { 6 | formatUntappdBeerInfoResult, 7 | formatUntappdBeerSearchResult, 8 | formatUntappdBreweryInfoResult, 9 | } from "./libs/format.js"; 10 | 11 | import dotenv from "dotenv"; 12 | import { GetBeerInfo } from "./api/getBeerInfo.js"; 13 | import { GetBreweryInfo } from "./api/getBreweryInfo.js"; 14 | dotenv.config(); 15 | 16 | export const config = { 17 | untappd: { 18 | clientId: process.env.UNTAPPED_API_CLIENT_ID, 19 | clientSecret: process.env.UNTAPPED_API_CLIENT_SECRET, 20 | }, 21 | }; 22 | 23 | // Create server instance 24 | const server = new McpServer({ 25 | name: "untapped", 26 | version: "1.0.0", 27 | }); 28 | 29 | // Register untapped tools 30 | server.tool( 31 | "Beer_Search", 32 | "Search beers on untapped", 33 | { 34 | beer: z.string().describe("The name of the beer you want to search"), 35 | }, 36 | async ({ beer }) => { 37 | const beersData = await getBeerSearch(beer); 38 | 39 | if (!beersData) { 40 | return { 41 | content: [ 42 | { 43 | type: "text", 44 | text: "Failed to retrieve untapped beer data.", 45 | }, 46 | ], 47 | }; 48 | } 49 | 50 | if (typeof beersData === "string") { 51 | return { 52 | content: [ 53 | { 54 | type: "text", 55 | text: `Failed to retrieve untapped beer data: ${beersData}`, 56 | }, 57 | ], 58 | }; 59 | } 60 | 61 | const formattedBeerData = formatUntappdBeerSearchResult(beersData); 62 | 63 | return { 64 | content: [ 65 | { 66 | type: "text", 67 | text: formattedBeerData, 68 | }, 69 | ], 70 | }; 71 | }, 72 | ); 73 | 74 | server.tool( 75 | "Beer_Info", 76 | "Get detailed info of a beer.", 77 | { 78 | bid: z 79 | .string() 80 | .describe( 81 | "Beer ID (string): The 'bid' can be retrieved from 'Beer Search'.", 82 | ), 83 | }, 84 | async ({ bid }) => { 85 | const beerInfoData = await GetBeerInfo(bid); 86 | 87 | if (!beerInfoData) { 88 | return { 89 | content: [ 90 | { 91 | type: "text", 92 | text: "Failed to retrieve untapped beer info data.", 93 | }, 94 | ], 95 | }; 96 | } 97 | 98 | if (typeof beerInfoData === "string") { 99 | return { 100 | content: [ 101 | { 102 | type: "text", 103 | text: `Failed to retrieve untapped beer info data: ${beerInfoData}`, 104 | }, 105 | ], 106 | }; 107 | } 108 | 109 | const formattedBeerInfoData = formatUntappdBeerInfoResult(beerInfoData); 110 | 111 | return { 112 | content: [ 113 | { 114 | type: "text", 115 | text: formattedBeerInfoData, 116 | }, 117 | ], 118 | }; 119 | }, 120 | ); 121 | 122 | server.tool( 123 | "Brewery_Info", 124 | "Get detailed info of a brewery.", 125 | { 126 | brewery_id: z 127 | .string() 128 | .describe( 129 | "brewery_id (string): The 'brewery_id' can be retrieved from a beer.", 130 | ), 131 | }, 132 | async ({ brewery_id }) => { 133 | const breweryInfoData = await GetBreweryInfo(brewery_id); 134 | 135 | if (!breweryInfoData) { 136 | return { 137 | content: [ 138 | { 139 | type: "text", 140 | text: "Failed to retrieve untapped brewery info data.", 141 | }, 142 | ], 143 | }; 144 | } 145 | 146 | if (typeof breweryInfoData === "string") { 147 | return { 148 | content: [ 149 | { 150 | type: "text", 151 | text: `Failed to retrieve untapped brewery info data: ${breweryInfoData}`, 152 | }, 153 | ], 154 | }; 155 | } 156 | 157 | const formattedBreweryInfoData = 158 | formatUntappdBreweryInfoResult(breweryInfoData); 159 | 160 | return { 161 | content: [ 162 | { 163 | type: "text", 164 | text: formattedBreweryInfoData, 165 | }, 166 | ], 167 | }; 168 | }, 169 | ); 170 | 171 | async function main() { 172 | const transport = new StdioServerTransport(); 173 | await server.connect(transport); 174 | console.error("Untapped MCP Server running on stdio"); 175 | } 176 | 177 | main().catch((error) => { 178 | console.error("Fatal error in main():", error); 179 | process.exit(1); 180 | }); 181 | ``` -------------------------------------------------------------------------------- /src/types/untappedApi.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Common types 2 | 3 | // Contact information for brewery 4 | interface UntappdContact { 5 | twitter?: string; 6 | facebook?: string; 7 | instagram?: string; 8 | url?: string; 9 | } 10 | 11 | // Location information for brewery 12 | interface UntappdLocation { 13 | brewery_city?: string; 14 | brewery_state?: string; 15 | venue_address?: string; 16 | venue_city?: string; 17 | venue_state?: string; 18 | lat?: number; 19 | lng?: number; 20 | } 21 | 22 | // Brewery details 23 | interface UntappdBrewery { 24 | brewery_id: number; 25 | brewery_name: string; 26 | brewery_slug?: string; 27 | brewery_label: string; 28 | country_name: string; 29 | contact: UntappdContact; 30 | location: UntappdLocation; 31 | brewery_active?: number; 32 | beer_count?: number; // Only in breweries section 33 | } 34 | 35 | // Beer details 36 | interface UntappdBeer { 37 | bid: number; 38 | beer_name: string; 39 | beer_label: string; 40 | beer_abv: number; 41 | beer_ibu: number; 42 | beer_description: string; 43 | created_at: string; 44 | beer_style: string; 45 | auth_rating: number; 46 | wish_list: boolean; 47 | in_production?: number; 48 | beer_slug?: string; 49 | beer_style_id?: number; 50 | beer_active?: number; 51 | is_in_production?: number; 52 | is_vintage?: number; 53 | is_variant?: number; 54 | is_homebrew?: number; 55 | rating_count?: number; 56 | rating_score?: number; 57 | } 58 | 59 | // Beer stats 60 | interface UntappdBeerStats { 61 | total_count: number; 62 | monthly_count: number; 63 | total_user_count: number; 64 | user_count: number; 65 | } 66 | 67 | // User information 68 | interface UntappdUser { 69 | uid: number; 70 | user_name: string; 71 | first_name: string; 72 | last_name: string; 73 | user_avatar: string; 74 | relationship: string; 75 | is_private: number; 76 | } 77 | 78 | // Venue category 79 | interface UntappdVenueCategory { 80 | category_name: string; 81 | category_id: string; 82 | is_primary: boolean; 83 | } 84 | 85 | // Venue categories section 86 | interface UntappdVenueCategories { 87 | count: number; 88 | items: UntappdVenueCategory[]; 89 | } 90 | 91 | // Foursquare venue info 92 | interface UntappdFoursquareVenue { 93 | foursquare_id: string; 94 | foursquare_url: string; 95 | } 96 | 97 | // Venue icon 98 | interface UntappdVenueIcon { 99 | sm: string; 100 | md: string; 101 | lg: string; 102 | } 103 | 104 | // Venue information 105 | interface UntappdVenue { 106 | venue_id: number; 107 | venue_name: string; 108 | primary_category: string; 109 | parent_category_id: string; 110 | categories: UntappdVenueCategories; 111 | location: UntappdLocation; 112 | contact: UntappdContact; 113 | private_venue: boolean; 114 | foursquare: UntappdFoursquareVenue; 115 | venue_icon: UntappdVenueIcon; 116 | } 117 | 118 | // Photo information 119 | interface UntappdPhoto { 120 | photo_img_sm: string; 121 | photo_img_md: string; 122 | photo_img_lg: string; 123 | photo_img_og: string; 124 | } 125 | 126 | // Media item for beer 127 | export interface UntappdMediaItem { 128 | photo_id: number; 129 | photo: UntappdPhoto; 130 | created_at: string; 131 | checkin_id: number; 132 | beer: UntappdBeer; 133 | brewery: UntappdBrewery; 134 | user: UntappdUser; 135 | venue: UntappdVenue[]; 136 | } 137 | 138 | // Media section 139 | export interface UntappdMedia { 140 | count: number; 141 | items: UntappdMediaItem | UntappdMediaItem[]; // API inconsistently returns object or array 142 | } 143 | 144 | // Similar beer item 145 | interface UntappdSimilarBeerItem { 146 | rating_score: number; 147 | beer: UntappdBeer; 148 | brewery: UntappdBrewery; 149 | friends: { 150 | items: any[]; // Can be more specific if needed 151 | count: number; 152 | }; 153 | } 154 | 155 | // Similar beers section 156 | interface UntappdSimilarBeers { 157 | count: number; 158 | items: UntappdSimilarBeerItem | UntappdSimilarBeerItem[]; // API inconsistently returns object or array 159 | } 160 | 161 | // Friends section 162 | interface UntappdFriends { 163 | count: number; 164 | items: any[]; // Can be more specific if needed 165 | } 166 | 167 | // Vintage item 168 | interface UntappdVintageItem { 169 | beer: { 170 | bid: number; 171 | beer_label: string; 172 | beer_slug: string; 173 | beer_name: string; 174 | is_vintage: number; 175 | is_variant: number; 176 | }; 177 | } 178 | 179 | // Vintages section 180 | interface UntappdVintages { 181 | count: number; 182 | items: UntappdVintageItem[]; 183 | } 184 | 185 | // Complete beer info 186 | export interface UntappdBeerInfo extends UntappdBeer { 187 | stats: UntappdBeerStats; 188 | brewery: UntappdBrewery; 189 | media: UntappdMedia; 190 | similar: UntappdSimilarBeers; 191 | friends: UntappdFriends; 192 | vintages: UntappdVintages; 193 | } 194 | 195 | // Beer item in search results 196 | export interface UntappdBeerItem { 197 | checkin_count: number; 198 | have_had: boolean; 199 | your_count: number; 200 | beer: UntappdBeer; 201 | brewery: UntappdBrewery; 202 | } 203 | 204 | // Brewery item in search results 205 | interface UntappdBreweryItem { 206 | brewery: UntappdBrewery; 207 | } 208 | 209 | // Beer section in response 210 | interface UntappdBeerSection { 211 | count: number; 212 | items: UntappdBeerItem[]; 213 | } 214 | 215 | // Brewery section in response 216 | interface UntappdBrewerySection { 217 | count: number; 218 | items: UntappdBreweryItem[]; 219 | } 220 | 221 | // Response wrapper with metadata (common to all Untappd API responses) 222 | export interface UntappdApiResponse<T> { 223 | meta: { 224 | code: number; 225 | response_time: { 226 | time: number; 227 | measure: string; 228 | }; 229 | error_detail?: string; 230 | error_type?: string; 231 | developer_friendly?: string; 232 | }; 233 | notifications: Record<string, unknown>; 234 | response: T; 235 | } 236 | 237 | // Untappd API Error Response 238 | export interface UntappdApiErrorResponse { 239 | meta: { 240 | code: number; 241 | error_detail: string; 242 | error_type: string; 243 | developer_friendly?: string; 244 | response_time: { 245 | time: number; 246 | measure: string; 247 | }; 248 | }; 249 | } 250 | 251 | // Complete beer info response 252 | export interface UntappdBeerInfoResponse { 253 | beer: UntappdBeerInfo; 254 | } 255 | 256 | // Complete search response 257 | export interface UntappdBeerSearchResponse { 258 | found: number; 259 | offset: number; 260 | limit: number; 261 | term: string; 262 | parsed_term: string; 263 | beers: UntappdBeerSection; 264 | homebrew: UntappdBeerSection; 265 | breweries: UntappdBrewerySection; 266 | } 267 | 268 | // Additional Untappd types for brewery information 269 | 270 | // Claimed status information for brewery 271 | interface UntappdClaimedStatus { 272 | is_claimed: boolean; 273 | claimed_slug: string; 274 | follow_status: boolean; 275 | follower_count: number; 276 | uid: number; 277 | mute_status: string; 278 | } 279 | 280 | // Brewery rating information 281 | interface UntappdBreweryRating { 282 | count: number; 283 | rating_score: number; 284 | } 285 | 286 | // Extended brewery statistics 287 | interface UntappdBreweryStats { 288 | total_count: number; 289 | unique_count: number; 290 | monthly_count: number; 291 | weekly_count: number; 292 | user_count: number; 293 | age_on_service: number; 294 | } 295 | 296 | // Brewery owners section 297 | interface UntappdBreweryOwners { 298 | count: number; 299 | items: any[]; // Could be more specific if needed 300 | } 301 | 302 | // Beer list item in brewery response 303 | interface UntappdBeerListItem { 304 | has_had: boolean; 305 | total_count: number; 306 | beer: UntappdBeer; 307 | brewery: UntappdBrewery; 308 | friends: any[]; // Could be more specific if needed 309 | } 310 | 311 | // Beer list section in brewery response 312 | interface UntappdBeerList { 313 | is_super: boolean; 314 | sort: string; 315 | filter: string; 316 | count: number; 317 | items: UntappdBeerListItem | UntappdBeerListItem[]; // API inconsistently returns object or array 318 | beer_count: number; 319 | } 320 | 321 | // Extended brewery information 322 | export interface UntappdBreweryInfo extends UntappdBrewery { 323 | brewery_in_production: number; 324 | is_independent: number; 325 | claimed_status: UntappdClaimedStatus; 326 | brewery_type: string; 327 | brewery_type_id: number; 328 | brewery_description: string; 329 | rating: UntappdBreweryRating; 330 | stats: UntappdBreweryStats; 331 | owners: UntappdBreweryOwners; 332 | media: UntappdMedia; 333 | beer_list: UntappdBeerList; 334 | } 335 | 336 | // Complete brewery info response 337 | export interface UntappdBreweryInfoResponse { 338 | brewery: UntappdBreweryInfo; 339 | } 340 | 341 | // Complete typed response for brewery info 342 | export type UntappdBreweryInfoResult = 343 | UntappdApiResponse<UntappdBreweryInfoResponse>; 344 | 345 | // Complete typed response for beer search 346 | export type UntappdBeerSearchResult = 347 | UntappdApiResponse<UntappdBeerSearchResponse>; 348 | 349 | // Complete typed response for beer info 350 | export type UntappdBeerInfoResult = UntappdApiResponse<UntappdBeerInfoResponse>; 351 | ``` -------------------------------------------------------------------------------- /src/libs/format.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | UntappdBeerInfo, 3 | UntappdBeerInfoResult, 4 | UntappdBeerItem, 5 | UntappdBeerSearchResult, 6 | UntappdBreweryInfoResult, 7 | UntappdMediaItem, 8 | } from "../types/untappedApi.js"; 9 | 10 | export function formatUntappdBeerItem(beerItem: UntappdBeerItem): string { 11 | return [ 12 | `Basic beer item properties`, 13 | `---`, 14 | `checkin_count: ${beerItem.checkin_count}`, 15 | `have_had: ${beerItem.have_had}`, 16 | `your_count: ${beerItem.your_count}`, 17 | 18 | `Beer properties`, 19 | `---`, 20 | `bid: ${beerItem.beer.bid}`, 21 | `beer_name: ${beerItem.beer.beer_name}`, 22 | `beer_label: ${beerItem.beer.beer_label}`, 23 | `beer_abv: ${beerItem.beer.beer_abv}`, 24 | `beer_ibu: ${beerItem.beer.beer_ibu}`, 25 | `beer_description: ${beerItem.beer.beer_description}`, 26 | `created_at: ${beerItem.beer.created_at}`, 27 | `beer_style: ${beerItem.beer.beer_style}`, 28 | `auth_rating: ${beerItem.beer.auth_rating}`, 29 | `wish_list: ${beerItem.beer.wish_list}`, 30 | 31 | `Optional beer properties`, 32 | `---`, 33 | ...(beerItem.beer.in_production !== undefined 34 | ? [`in_production: ${beerItem.beer.in_production}`] 35 | : []), 36 | ...(beerItem.beer.beer_slug 37 | ? [`beer_slug: ${beerItem.beer.beer_slug}`] 38 | : []), 39 | ...(beerItem.beer.beer_style_id 40 | ? [`beer_style_id: ${beerItem.beer.beer_style_id}`] 41 | : []), 42 | ...(beerItem.beer.beer_active !== undefined 43 | ? [`beer_active: ${beerItem.beer.beer_active}`] 44 | : []), 45 | ...(beerItem.beer.is_in_production !== undefined 46 | ? [`is_in_production: ${beerItem.beer.is_in_production}`] 47 | : []), 48 | ...(beerItem.beer.is_vintage !== undefined 49 | ? [`is_vintage: ${beerItem.beer.is_vintage}`] 50 | : []), 51 | ...(beerItem.beer.is_variant !== undefined 52 | ? [`is_variant: ${beerItem.beer.is_variant}`] 53 | : []), 54 | ...(beerItem.beer.is_homebrew !== undefined 55 | ? [`is_homebrew: ${beerItem.beer.is_homebrew}`] 56 | : []), 57 | ...(beerItem.beer.rating_count !== undefined 58 | ? [`rating_count: ${beerItem.beer.rating_count}`] 59 | : []), 60 | ...(beerItem.beer.rating_score !== undefined 61 | ? [`rating_score: ${beerItem.beer.rating_score}`] 62 | : []), 63 | 64 | `Brewery properties`, 65 | `---`, 66 | `brewery_id: ${beerItem.brewery.brewery_id}`, 67 | `brewery_name: ${beerItem.brewery.brewery_name}`, 68 | `brewery_label: ${beerItem.brewery.brewery_label}`, 69 | `country_name: ${beerItem.brewery.country_name}`, 70 | 71 | `Optional brewery properties`, 72 | `---`, 73 | ...(beerItem.brewery.brewery_slug 74 | ? [`brewery_slug: ${beerItem.brewery.brewery_slug}`] 75 | : []), 76 | ...(beerItem.brewery.brewery_active !== undefined 77 | ? [`brewery_active: ${beerItem.brewery.brewery_active}`] 78 | : []), 79 | ...(beerItem.brewery.beer_count !== undefined 80 | ? [`brewery_beer_count: ${beerItem.brewery.beer_count}`] 81 | : []), 82 | 83 | `Brewery contact information`, 84 | `---`, 85 | ...(beerItem.brewery.contact?.twitter 86 | ? [`brewery_twitter: ${beerItem.brewery.contact.twitter}`] 87 | : []), 88 | ...(beerItem.brewery.contact?.facebook 89 | ? [`brewery_facebook: ${beerItem.brewery.contact.facebook}`] 90 | : []), 91 | ...(beerItem.brewery.contact?.instagram 92 | ? [`brewery_instagram: ${beerItem.brewery.contact.instagram}`] 93 | : []), 94 | ...(beerItem.brewery.contact?.url 95 | ? [`brewery_url: ${beerItem.brewery.contact.url}`] 96 | : []), 97 | 98 | `Brewery location`, 99 | `---`, 100 | ...(beerItem.brewery.location?.brewery_city 101 | ? [`brewery_city: ${beerItem.brewery.location.brewery_city}`] 102 | : []), 103 | ...(beerItem.brewery.location?.brewery_state 104 | ? [`brewery_state: ${beerItem.brewery.location.brewery_state}`] 105 | : []), 106 | ...(beerItem.brewery.location?.venue_address 107 | ? [`venue_address: ${beerItem.brewery.location.venue_address}`] 108 | : []), 109 | ...(beerItem.brewery.location?.venue_city 110 | ? [`venue_city: ${beerItem.brewery.location.venue_city}`] 111 | : []), 112 | ...(beerItem.brewery.location?.venue_state 113 | ? [`venue_state: ${beerItem.brewery.location.venue_state}`] 114 | : []), 115 | ...(beerItem.brewery.location?.lat !== undefined 116 | ? [`latitude: ${beerItem.brewery.location.lat}`] 117 | : []), 118 | ...(beerItem.brewery.location?.lng !== undefined 119 | ? [`longitude: ${beerItem.brewery.location.lng}`] 120 | : []), 121 | "+++ End", 122 | ].join("\n"); 123 | } 124 | 125 | export function formatUntappdBeerInfo(beerInfo: UntappdBeerInfo): string { 126 | return [ 127 | `Beer Information`, 128 | `---`, 129 | `bid: ${beerInfo.bid}`, 130 | `beer_name: ${beerInfo.beer_name}`, 131 | `beer_label: ${beerInfo.beer_label}`, 132 | `beer_abv: ${beerInfo.beer_abv}`, 133 | `beer_ibu: ${beerInfo.beer_ibu}`, 134 | `beer_description: ${beerInfo.beer_description}`, 135 | `created_at: ${beerInfo.created_at}`, 136 | `beer_style: ${beerInfo.beer_style}`, 137 | `auth_rating: ${beerInfo.auth_rating}`, 138 | 139 | `Optional Beer Properties`, 140 | `---`, 141 | ...(beerInfo.in_production !== undefined 142 | ? [`in_production: ${beerInfo.in_production}`] 143 | : []), 144 | ...(beerInfo.beer_slug ? [`beer_slug: ${beerInfo.beer_slug}`] : []), 145 | ...(beerInfo.beer_style_id 146 | ? [`beer_style_id: ${beerInfo.beer_style_id}`] 147 | : []), 148 | ...(beerInfo.beer_active !== undefined 149 | ? [`beer_active: ${beerInfo.beer_active}`] 150 | : []), 151 | ...(beerInfo.is_in_production !== undefined 152 | ? [`is_in_production: ${beerInfo.is_in_production}`] 153 | : []), 154 | ...(beerInfo.is_vintage !== undefined 155 | ? [`is_vintage: ${beerInfo.is_vintage}`] 156 | : []), 157 | ...(beerInfo.is_variant !== undefined 158 | ? [`is_variant: ${beerInfo.is_variant}`] 159 | : []), 160 | ...(beerInfo.is_homebrew !== undefined 161 | ? [`is_homebrew: ${beerInfo.is_homebrew}`] 162 | : []), 163 | ...(beerInfo.rating_count !== undefined 164 | ? [`rating_count: ${beerInfo.rating_count}`] 165 | : []), 166 | ...(beerInfo.rating_score !== undefined 167 | ? [`rating_score: ${beerInfo.rating_score}`] 168 | : []), 169 | 170 | `Beer Stats`, 171 | `---`, 172 | `total_count: ${beerInfo.stats.total_count}`, 173 | `monthly_count: ${beerInfo.stats.monthly_count}`, 174 | `total_user_count: ${beerInfo.stats.total_user_count}`, 175 | `user_count: ${beerInfo.stats.user_count}`, 176 | 177 | `Brewery Information`, 178 | `---`, 179 | `brewery_id: ${beerInfo.brewery.brewery_id}`, 180 | `brewery_name: ${beerInfo.brewery.brewery_name}`, 181 | `brewery_label: ${beerInfo.brewery.brewery_label}`, 182 | `country_name: ${beerInfo.brewery.country_name}`, 183 | 184 | `Optional Brewery Properties`, 185 | `---`, 186 | ...(beerInfo.brewery.brewery_slug 187 | ? [`brewery_slug: ${beerInfo.brewery.brewery_slug}`] 188 | : []), 189 | 190 | `Brewery Location`, 191 | `---`, 192 | ...(beerInfo.brewery.location?.brewery_city 193 | ? [`brewery_city: ${beerInfo.brewery.location.brewery_city}`] 194 | : []), 195 | ...(beerInfo.brewery.location?.brewery_state 196 | ? [`brewery_state: ${beerInfo.brewery.location.brewery_state}`] 197 | : []), 198 | ...(beerInfo.brewery.location?.venue_address 199 | ? [`venue_address: ${beerInfo.brewery.location.venue_address}`] 200 | : []), 201 | ...(beerInfo.brewery.location?.venue_city 202 | ? [`venue_city: ${beerInfo.brewery.location.venue_city}`] 203 | : []), 204 | ...(beerInfo.brewery.location?.venue_state 205 | ? [`venue_state: ${beerInfo.brewery.location.venue_state}`] 206 | : []), 207 | ...(beerInfo.brewery.location?.lat !== undefined 208 | ? [`latitude: ${beerInfo.brewery.location.lat}`] 209 | : []), 210 | ...(beerInfo.brewery.location?.lng !== undefined 211 | ? [`longitude: ${beerInfo.brewery.location.lng}`] 212 | : []), 213 | 214 | `Media Information`, 215 | `---`, 216 | `media_count: ${beerInfo.media.count}`, 217 | 218 | `Similar Beers`, 219 | `---`, 220 | `similar_beers_count: ${beerInfo.similar.count}`, 221 | 222 | `Friends Information`, 223 | `---`, 224 | `friends_count: ${beerInfo.friends.count}`, 225 | 226 | `Vintages Information`, 227 | `---`, 228 | `vintages_count: ${beerInfo.vintages.count}`, 229 | ...(beerInfo.vintages.count > 0 ? [`Has vintage versions available`] : []), 230 | 231 | "Media Information", 232 | Array.isArray(beerInfo.media.items) 233 | ? beerInfo.media.items.map((m) => formatUntappdMediaItem(m)) 234 | : formatUntappdMediaItem(beerInfo.media.items), 235 | `+++ End`, 236 | ].join("\n"); 237 | } 238 | 239 | export function formatUntappdMediaItem(item: UntappdMediaItem): string { 240 | return [ 241 | `Media Item Information`, 242 | `---`, 243 | `created_at: ${item.created_at}`, 244 | 245 | `Beer Information`, 246 | `---`, 247 | `bid: ${item.beer.bid}`, 248 | `beer_name: ${item.beer.beer_name}`, 249 | `beer_style: ${item.beer.beer_style}`, 250 | `beer_abv: ${item.beer.beer_abv}`, 251 | 252 | `Brewery Information`, 253 | `---`, 254 | `brewery_id: ${item.brewery.brewery_id}`, 255 | `brewery_name: ${item.brewery.brewery_name}`, 256 | `country_name: ${item.brewery.country_name}`, 257 | 258 | `Venue Information`, 259 | `---`, 260 | ...(Array.isArray(item.venue) && item.venue.length > 0 261 | ? [ 262 | `venue_id: ${item.venue[0].venue_id}`, 263 | `venue_name: ${item.venue[0].venue_name}`, 264 | `primary_category: ${item.venue[0].primary_category}`, 265 | ...(item.venue[0].location?.venue_city 266 | ? [`venue_city: ${item.venue[0].location.venue_city}`] 267 | : []), 268 | ...(item.venue[0].location?.venue_state 269 | ? [`venue_state: ${item.venue[0].location.venue_state}`] 270 | : []), 271 | ] 272 | : [`No venue information available`]), 273 | `+++ End`, 274 | ].join("\n"); 275 | } 276 | 277 | export function formatUntappdBeerSearchResult( 278 | result: UntappdBeerSearchResult, 279 | ): string { 280 | const payload: string[] = []; 281 | 282 | result.response.beers.items.forEach((beer: UntappdBeerItem) => { 283 | payload.push(formatUntappdBeerItem(beer)); 284 | }); 285 | 286 | return payload.join("\n"); 287 | } 288 | 289 | export function formatUntappdBeerInfoResult( 290 | result: UntappdBeerInfoResult, 291 | ): string { 292 | return formatUntappdBeerInfo(result.response.beer); 293 | } 294 | 295 | export function formatUntappdBreweryInfoResult( 296 | result: UntappdBreweryInfoResult, 297 | ): string { 298 | const breweryInfo = result.response.brewery; 299 | 300 | return [ 301 | `Brewery Information`, 302 | `---`, 303 | `brewery_id: ${breweryInfo.brewery_id}`, 304 | `brewery_name: ${breweryInfo.brewery_name}`, 305 | `country_name: ${breweryInfo.country_name}`, 306 | `brewery_in_production: ${breweryInfo.brewery_in_production}`, 307 | `is_independent: ${breweryInfo.is_independent}`, 308 | ...(breweryInfo.brewery_slug 309 | ? [`brewery_slug: ${breweryInfo.brewery_slug}`] 310 | : []), 311 | ...(breweryInfo.brewery_type 312 | ? [`brewery_type: ${breweryInfo.brewery_type}`] 313 | : []), 314 | ...(breweryInfo.brewery_type_id 315 | ? [`brewery_type_id: ${breweryInfo.brewery_type_id}`] 316 | : []), 317 | ...(breweryInfo.brewery_description 318 | ? [`brewery_description: ${breweryInfo.brewery_description}`] 319 | : []), 320 | `beer_count: ${breweryInfo.beer_count}`, 321 | 322 | `Contact Information`, 323 | `---`, 324 | ...(breweryInfo.contact?.url 325 | ? [`website: ${breweryInfo.contact.url}`] 326 | : []), 327 | 328 | `Location`, 329 | `---`, 330 | ...(breweryInfo.location?.venue_address 331 | ? [`address: ${breweryInfo.location.venue_address}`] 332 | : []), 333 | ...(breweryInfo.location?.brewery_city 334 | ? [`city: ${breweryInfo.location.brewery_city}`] 335 | : []), 336 | ...(breweryInfo.location?.brewery_state 337 | ? [`state: ${breweryInfo.location.brewery_state}`] 338 | : []), 339 | ...(breweryInfo.location?.lat !== undefined 340 | ? [`latitude: ${breweryInfo.location.lat}`] 341 | : []), 342 | ...(breweryInfo.location?.lng !== undefined 343 | ? [`longitude: ${breweryInfo.location.lng}`] 344 | : []), 345 | 346 | `Rating`, 347 | `---`, 348 | `rating_count: ${breweryInfo.rating.count}`, 349 | `rating_score: ${breweryInfo.rating.rating_score}`, 350 | 351 | `Statistics`, 352 | `---`, 353 | `total_check_ins: ${breweryInfo.stats.total_count}`, 354 | `unique_users: ${breweryInfo.stats.unique_count}`, 355 | `monthly_check_ins: ${breweryInfo.stats.monthly_count}`, 356 | `weekly_check_ins: ${breweryInfo.stats.weekly_count}`, 357 | `user_count: ${breweryInfo.stats.user_count}`, 358 | `age_on_service: ${breweryInfo.stats.age_on_service}`, 359 | 360 | `Beer List`, 361 | `---`, 362 | `is_super: ${breweryInfo.beer_list.is_super}`, 363 | `sort: ${breweryInfo.beer_list.sort || "default"}`, 364 | `filter: ${breweryInfo.beer_list.filter}`, 365 | `beer_count: ${breweryInfo.beer_list.beer_count}`, 366 | `displayed_beers: ${breweryInfo.beer_list.count}`, 367 | 368 | `Brewery Beers`, 369 | `---`, 370 | ...(breweryInfo.beer_list.count > 0 371 | ? Array.isArray(breweryInfo.beer_list.items) 372 | ? breweryInfo.beer_list.items.map((beer) => [ 373 | `bid: ${beer.beer.bid}`, 374 | `beer_name: ${beer.beer.beer_name}`, 375 | `beer_style: ${beer.beer.beer_style}`, 376 | `beer_abv: ${beer.beer.beer_abv}`, 377 | `beer_ibu: ${beer.beer.beer_ibu}`, 378 | `beer_description: ${beer.beer.beer_description}`, 379 | `rating_score: ${beer.beer.rating_score}`, 380 | `rating_count: ${beer.beer.rating_count}`, 381 | "\n", 382 | ]) 383 | : "" 384 | : [`No beers available to display`]), 385 | 386 | `+++ End`, 387 | ].join("\n"); 388 | } 389 | ```