import 'dotenv/config';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import express, { Request, Response } from 'express';
import cors from 'cors';
import { createClient } from '@sanity/client';
import { z } from 'zod';
// Environment
const env = {
SANITY_PROJECT_ID: process.env.SANITY_PROJECT_ID!,
SANITY_DATASET: process.env.SANITY_DATASET || 'production',
SANITY_API_VERSION: process.env.SANITY_API_VERSION || '2024-01-01',
SANITY_TOKEN: process.env.SANITY_TOKEN,
PORT: process.env.PORT || 3000,
};
if (!env.SANITY_PROJECT_ID) {
console.error('Missing SANITY_PROJECT_ID');
process.exit(1);
}
// Sanity clients
const sanityClient = createClient({
projectId: env.SANITY_PROJECT_ID,
dataset: env.SANITY_DATASET,
apiVersion: env.SANITY_API_VERSION,
token: env.SANITY_TOKEN,
useCdn: true,
});
const sanityWriteClient = createClient({
projectId: env.SANITY_PROJECT_ID,
dataset: env.SANITY_DATASET,
apiVersion: env.SANITY_API_VERSION,
token: env.SANITY_TOKEN,
useCdn: false,
});
// GROQ Queries
const QUERIES = {
eventsSearch: `
*[_type == "event" && status == "published"
&& ($query == null || title match $query || description match $query)
&& ($locationSlug == null || location->slug.current == $locationSlug)
&& ($categorySlug == null || $categorySlug in categories[]->slug.current)
&& ($startDate == null || startDate >= $startDate)
&& ($endDate == null || startDate <= $endDate)
&& ($isFree == null || pricing.isFree == $isFree)
] | order(startDate asc) [0...$limit] {
_id, title, "slug": slug.current, description, startDate, endDate, isAllDay,
"location": location->{ _id, city, "slug": slug.current, country },
"venue": venue->{ _id, name, "slug": slug.current, address },
"categories": categories[]->{ _id, name, "slug": slug.current, icon, color },
pricing, organizer, tags, "imageUrl": images[0].asset->url
}
`,
eventBySlug: `
*[_type == "event" && slug.current == $slug][0] {
_id, title, "slug": slug.current, description, startDate, endDate, isAllDay,
"location": location->{ _id, city, "slug": slug.current, region, country, coordinates, timezone },
"venue": venue->{ _id, name, "slug": slug.current, address, coordinates, capacity },
"categories": categories[]->{ _id, name, "slug": slug.current, icon, color },
pricing, organizer, externalLinks, tags, status, source,
"images": images[]{ "url": asset->url, "alt": alt }
}
`,
eventsUpcoming: `
*[_type == "event" && status == "published" && startDate >= now()
&& ($locationSlug == null || location->slug.current == $locationSlug)
&& ($categorySlug == null || $categorySlug in categories[]->slug.current)
] | order(startDate asc) [0...$limit] {
_id, title, "slug": slug.current, description, startDate, endDate, isAllDay,
"location": location->{ _id, city, "slug": slug.current, country },
"venue": venue->{ _id, name, "slug": slug.current },
"categories": categories[]->{ _id, name, "slug": slug.current, icon, color },
pricing, tags, "imageUrl": images[0].asset->url
}
`,
locationsList: `
*[_type == "location" && ($country == null || country == $country)] | order(city asc) {
_id, city, "slug": slug.current, region, country, locationType, coordinates, timezone,
"eventCount": $includeEventCounts => count(*[_type == "event" && status == "published" && startDate >= now() && references(^._id)])
}
`,
locationBySlug: `*[_type == "location" && slug.current == $slug][0] { _id, city, "slug": slug.current, region, country }`,
categoriesList: `
*[_type == "category"] | order(name asc) {
_id, name, "slug": slug.current, icon, color, "parentSlug": parentCategory->slug.current,
"eventCount": $includeEventCounts => count(*[_type == "event" && status == "published" && startDate >= now() && references(^._id)])
}
`,
categoryBySlug: `*[_type == "category" && slug.current == $slug][0] { _id, name, "slug": slug.current, icon, color }`,
venuesList: `
*[_type == "venue" && ($locationSlug == null || location->slug.current == $locationSlug)] | order(name asc) {
_id, name, "slug": slug.current,
"location": location->{ _id, city, "slug": slug.current },
address, coordinates, capacity
}
`,
venueBySlug: `*[_type == "venue" && slug.current == $slug][0] { _id, name, "slug": slug.current, "location": location->{ _id, city, "slug": slug.current, country }, address, coordinates, capacity }`,
};
// Input schemas
const eventsSearchInputSchema = z.object({
query: z.string().optional().describe('Search query for event title or description'),
locationSlug: z.string().optional().describe('Filter by location slug (e.g., "oslo", "bergen")'),
categorySlug: z.string().optional().describe('Filter by category slug (e.g., "musikk", "sport")'),
startDate: z.string().optional().describe('Filter events starting from this date (ISO format)'),
endDate: z.string().optional().describe('Filter events ending before this date (ISO format)'),
isFree: z.boolean().optional().describe('Filter for free events only'),
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results'),
});
const eventsGetInputSchema = z.object({
slug: z.string().describe('Event slug to retrieve'),
});
const eventsUpcomingInputSchema = z.object({
locationSlug: z.string().optional().describe('Filter by location slug'),
categorySlug: z.string().optional().describe('Filter by category slug'),
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results'),
});
const eventsCreateInputSchema = z.object({
title: z.string().describe('Event title'),
description: z.string().optional().describe('Event description'),
startDate: z.string().describe('Start date and time (ISO format)'),
endDate: z.string().optional().describe('End date and time (ISO format)'),
isAllDay: z.boolean().optional().describe('Is this an all-day event'),
locationSlug: z.string().describe('Location slug'),
venueSlug: z.string().optional().describe('Venue slug'),
categorySlugs: z.array(z.string()).optional().describe('Category slugs'),
isFree: z.boolean().optional().describe('Is the event free'),
minPrice: z.number().optional().describe('Minimum ticket price'),
maxPrice: z.number().optional().describe('Maximum ticket price'),
organizerName: z.string().optional().describe('Organizer name'),
tags: z.array(z.string()).optional().describe('Event tags'),
});
const locationsListInputSchema = z.object({
country: z.string().optional().describe('Filter by country'),
includeEventCounts: z.boolean().optional().describe('Include count of upcoming events'),
});
const categoriesListInputSchema = z.object({
includeEventCounts: z.boolean().optional().describe('Include count of upcoming events'),
});
const venuesListInputSchema = z.object({
locationSlug: z.string().optional().describe('Filter by location slug'),
});
// Helper
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/æ/g, 'ae')
.replace(/ø/g, 'o')
.replace(/å/g, 'a')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
// Create MCP server
function createMcpServer() {
const server = new McpServer({
name: 'evhenter',
version: '1.0.0',
});
server.tool(
'events_search',
'Search for events by query, location, category, date range, and pricing. Use this to find events matching specific criteria.',
eventsSearchInputSchema.shape,
async (input) => {
const params: Record<string, unknown> = {
query: input.query ? `*${input.query}*` : null,
locationSlug: input.locationSlug ?? null,
categorySlug: input.categorySlug ?? null,
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
isFree: input.isFree ?? null,
limit: input.limit,
};
const events = await sanityClient.fetch(QUERIES.eventsSearch, params);
return { content: [{ type: 'text', text: JSON.stringify({ events, count: events.length }, null, 2) }] };
}
);
server.tool(
'events_get',
'Get detailed information about a specific event by its slug',
eventsGetInputSchema.shape,
async (input) => {
const event = await sanityClient.fetch(QUERIES.eventBySlug, { slug: input.slug });
if (!event) {
return { content: [{ type: 'text', text: JSON.stringify({ error: 'Event not found', slug: input.slug }) }] };
}
return { content: [{ type: 'text', text: JSON.stringify({ event }, null, 2) }] };
}
);
server.tool(
'events_upcoming',
'List upcoming events, optionally filtered by location or category. Perfect for "what\'s happening this weekend" queries.',
eventsUpcomingInputSchema.shape,
async (input) => {
const params: Record<string, unknown> = {
locationSlug: input.locationSlug ?? null,
categorySlug: input.categorySlug ?? null,
limit: input.limit,
};
const events = await sanityClient.fetch(QUERIES.eventsUpcoming, params);
return { content: [{ type: 'text', text: JSON.stringify({ events, count: events.length }, null, 2) }] };
}
);
server.tool(
'events_create',
'Create a new event (will be saved as draft for review)',
eventsCreateInputSchema.shape,
async (input) => {
const location = await sanityClient.fetch(QUERIES.locationBySlug, { slug: input.locationSlug });
if (!location) {
return { content: [{ type: 'text', text: JSON.stringify({ error: 'Location not found' }) }] };
}
let venue = null;
if (input.venueSlug) {
venue = await sanityClient.fetch(QUERIES.venueBySlug, { slug: input.venueSlug });
}
const categories: Array<{ _type: 'reference'; _ref: string }> = [];
if (input.categorySlugs?.length) {
for (const slug of input.categorySlugs) {
const category = await sanityClient.fetch(QUERIES.categoryBySlug, { slug });
if (category) categories.push({ _type: 'reference', _ref: category._id });
}
}
const slug = generateSlug(input.title);
const eventDoc = {
_type: 'event',
title: input.title,
slug: { _type: 'slug', current: `${slug}-${Date.now()}` },
description: input.description,
startDate: input.startDate,
endDate: input.endDate,
isAllDay: input.isAllDay ?? false,
location: { _type: 'reference', _ref: location._id },
...(venue && { venue: { _type: 'reference', _ref: venue._id } }),
categories,
pricing: { isFree: input.isFree ?? false, minPrice: input.minPrice, maxPrice: input.maxPrice, currency: 'NOK' },
organizer: { name: input.organizerName },
tags: input.tags ?? [],
status: 'draft',
source: { type: 'api' },
};
const created = await sanityWriteClient.create(eventDoc);
return {
content: [{ type: 'text', text: JSON.stringify({ success: true, event: { _id: created._id, title: input.title, slug: eventDoc.slug.current } }, null, 2) }],
};
}
);
server.tool(
'locations_list',
'List all available locations (cities) where events can be found',
locationsListInputSchema.shape,
async (input) => {
const params: Record<string, unknown> = {
country: input.country ?? null,
includeEventCounts: input.includeEventCounts ?? false,
};
const locations = await sanityClient.fetch(QUERIES.locationsList, params);
return { content: [{ type: 'text', text: JSON.stringify({ locations, count: locations.length }, null, 2) }] };
}
);
server.tool(
'categories_list',
'List all event categories (music, sports, art, food, etc.)',
categoriesListInputSchema.shape,
async (input) => {
const params: Record<string, unknown> = {
includeEventCounts: input.includeEventCounts ?? false,
};
const categories = await sanityClient.fetch(QUERIES.categoriesList, params);
return { content: [{ type: 'text', text: JSON.stringify({ categories, count: categories.length }, null, 2) }] };
}
);
server.tool(
'venues_list',
'List venues (concert halls, stadiums, etc.) optionally filtered by location',
venuesListInputSchema.shape,
async (input) => {
const params: Record<string, unknown> = {
locationSlug: input.locationSlug ?? null,
};
const venues = await sanityClient.fetch(QUERIES.venuesList, params);
return { content: [{ type: 'text', text: JSON.stringify({ venues, count: venues.length }, null, 2) }] };
}
);
return server;
}
// Express app
const app = express();
app.use(cors());
app.use(express.json());
// Store active transports
const transports = new Map<string, SSEServerTransport>();
// Health check
app.get('/', (_req, res) => {
res.json({ status: 'ok', name: 'evhenter-mcp', version: '1.0.0' });
});
app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
// SSE endpoint - client connects here
app.get('/sse', async (req: Request, res: Response) => {
console.log('New SSE connection');
const transport = new SSEServerTransport('/messages', res);
const sessionId = transport.sessionId;
transports.set(sessionId, transport);
const server = createMcpServer();
res.on('close', () => {
console.log(`SSE connection closed: ${sessionId}`);
transports.delete(sessionId);
});
await server.connect(transport);
await transport.start();
});
// Messages endpoint - receives POST messages
app.post('/messages', async (req: Request, res: Response) => {
const sessionId = req.query.sessionId as string;
if (!sessionId) {
res.status(400).json({ error: 'Missing sessionId' });
return;
}
const transport = transports.get(sessionId);
if (!transport) {
res.status(404).json({ error: 'Session not found' });
return;
}
await transport.handlePostMessage(req, res);
});
// Start server
const PORT = env.PORT;
app.listen(PORT, () => {
console.log(`EvHenter MCP server running on port ${PORT}`);
console.log(`SSE endpoint: /sse`);
console.log(`Messages endpoint: /messages`);
});