feat: MVP implementation — MCP server, partner one-pager, legal docs
Code (code/): - TypeScript MCP server with 5 tools (business info, hours, services, booking, related) - PostgreSQL schema with pilot business seed data - /.well-known/mcp-server manifest generator - Railway deployment config (Dockerfile, railway.json) - Multi-tenant gateway placeholder GTM (docs/gtm/): - Partner One-Pager: agency sales asset with economics, onboarding, competitive comparison Legal (docs/legal/): - Data Flow & Privacy: data collection, storage, sharing, GDPR/CCPA commitments - Partnership Agreement Skeleton: template for agency/CoC partnerships - IP Notes: well-known convention positioning, telemetry ownership, open-source strategy
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --production
|
||||
|
||||
COPY dist/ ./dist/
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
Generated
+2456
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "geolocal-io-mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "Multi-tenant MCP server for geolocal.io \u2014 AI-native local business discovery",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"seed": "tsx src/db/seed.ts",
|
||||
"test": "tsx --test tests/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"fastify": "^5.0.0",
|
||||
"pg": "^8.13.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "DOCKERFILE",
|
||||
"dockerfilePath": "./Dockerfile"
|
||||
},
|
||||
"deploy": {
|
||||
"startCommand": "npm start",
|
||||
"healthcheckPath": "/health",
|
||||
"healthcheckTimeout": 100,
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
databaseUrl: process.env.DATABASE_URL || 'postgresql://localhost:5432/geolocal',
|
||||
calcomBaseUrl: process.env.CALCOM_BASE_URL || 'https://api.cal.com',
|
||||
stripeApiKey: process.env.STRIPE_API_KEY || '',
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Pool } from 'pg';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const pool = new Pool({ connectionString: config.databaseUrl });
|
||||
|
||||
export async function initSchema() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS businesses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
category VARCHAR(100),
|
||||
address TEXT,
|
||||
city VARCHAR(200),
|
||||
state VARCHAR(50),
|
||||
zip VARCHAR(20),
|
||||
phone VARCHAR(50),
|
||||
website VARCHAR(500),
|
||||
hours JSONB DEFAULT '{}',
|
||||
services JSONB DEFAULT '[]',
|
||||
story TEXT,
|
||||
owner_bio TEXT,
|
||||
photos JSONB DEFAULT '[]',
|
||||
calcom_link VARCHAR(500),
|
||||
mcp_endpoint VARCHAR(500),
|
||||
related_business_ids INTEGER[] DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_businesses_slug ON businesses(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_businesses_category ON businesses(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_businesses_city_state ON businesses(city, state);
|
||||
`);
|
||||
}
|
||||
|
||||
export { pool };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { pool, initSchema } from './schema.js';
|
||||
|
||||
async function seed() {
|
||||
await initSchema();
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO businesses (slug, name, category, address, city, state, zip, phone, website, hours, services, story, calcom_link, photos)
|
||||
VALUES (
|
||||
'frisco-german-auto',
|
||||
'Frisco German Auto Specialists',
|
||||
'auto-repair',
|
||||
'1234 Main St',
|
||||
'Frisco',
|
||||
'TX',
|
||||
'75034',
|
||||
'(469) 555-0123',
|
||||
'https://friscogermanauto.example.com',
|
||||
'{"monday_friday": "8AM-6PM", "saturday": "9AM-3PM", "sunday": "closed"}',
|
||||
'[
|
||||
{"name": "Oil Change", "price": 65, "description": "Full synthetic oil change for European vehicles"},
|
||||
{"name": "Brake Inspection", "price": 100, "description": "Complete brake system inspection and quote"},
|
||||
{"name": "Diagnostics", "price": 150, "description": "Computer diagnostics for check engine light and electrical issues"},
|
||||
{"name": "Tire Rotation", "price": 40, "description": "Tire rotation and balancing"}
|
||||
]',
|
||||
'Family-run shop with 40 years of experience, specializing in BMW, Mercedes, and Audi. We treat every car like our own.',
|
||||
'https://cal.com/friscogermanauto',
|
||||
'["https://example.com/photos/shop-front.jpg", "https://example.com/photos/service-bay.jpg"]'
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
`);
|
||||
|
||||
console.log('\u2705 Seed data inserted');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
seed().catch(console.error);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { createMCPServer } from './mcp-server.js';
|
||||
import { initSchema } from './db/schema.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
async function main() {
|
||||
// Initialize database
|
||||
await initSchema();
|
||||
console.error('\u2705 Database schema initialized');
|
||||
|
||||
// Create and start MCP server
|
||||
const server = createMCPServer();
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
console.error(`\u2705 geolocal.io MCP server v0.1.0 running`);
|
||||
console.error(` Port: ${config.port}`);
|
||||
console.error(` Tools: get_business_info, get_hours, get_services, get_booking_link, get_related_businesses`);
|
||||
console.error(` Discovery: /.well-known/mcp-server`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\u274C Failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { pool } from './db/schema.js';
|
||||
|
||||
interface ManifestConfig {
|
||||
businessSlug: string;
|
||||
serverBaseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the /.well-known/mcp-server JSON manifest.
|
||||
*
|
||||
* Businesses (or their agencies) upload this file to their website root
|
||||
* at `/.well-known/mcp-server`. AI agents discover it automatically.
|
||||
*/
|
||||
export function generateManifest(config: ManifestConfig): Record<string, any> {
|
||||
const { businessSlug, serverBaseUrl } = config;
|
||||
return {
|
||||
mcp_server: `${serverBaseUrl}/mcp/${businessSlug}`,
|
||||
protocol: 'streamable-http',
|
||||
version: '0.1.0',
|
||||
business: businessSlug,
|
||||
provider: 'geolocal.io',
|
||||
discovery: {
|
||||
transport: 'stdio',
|
||||
tools: [
|
||||
'get_business_info',
|
||||
'get_hours',
|
||||
'get_services',
|
||||
'get_booking_link',
|
||||
'get_related_businesses',
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getManifestForBusiness(
|
||||
businessSlug: string,
|
||||
serverBaseUrl: string = 'https://mcp.geolocal.io'
|
||||
): Promise<Record<string, any>> {
|
||||
const result = await pool.query(
|
||||
`SELECT slug, name, mcp_endpoint FROM businesses WHERE slug = $1`,
|
||||
[businessSlug]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`Business '${businessSlug}' not found`);
|
||||
}
|
||||
|
||||
const b = result.rows[0];
|
||||
const baseUrl = b.mcp_endpoint || serverBaseUrl;
|
||||
return generateManifest({ businessSlug, serverBaseUrl: baseUrl });
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI: Generate a manifest file for a given business slug.
|
||||
* Usage: npx tsx src/manifest-generator.ts --slug=frisco-german-auto
|
||||
*/
|
||||
async function cli() {
|
||||
const args = process.argv.slice(2);
|
||||
const slugArg = args.find((a) => a.startsWith('--slug='));
|
||||
if (!slugArg) {
|
||||
console.error('Usage: npx tsx src/manifest-generator.ts --slug=<business-slug>');
|
||||
process.exit(1);
|
||||
}
|
||||
const slug = slugArg.split('=')[1];
|
||||
const manifest = await getManifestForBusiness(slug);
|
||||
console.log(JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
|
||||
if (process.argv[1]?.includes('manifest-generator')) {
|
||||
cli().catch(console.error);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { getBusinessInfo } from './tools/get-business-info.js';
|
||||
import { getHours } from './tools/get-hours.js';
|
||||
import { getServices } from './tools/get-services.js';
|
||||
import { getBookingLink } from './tools/get-booking-link.js';
|
||||
import { getRelatedBusinesses } from './tools/get-related-businesses.js';
|
||||
|
||||
/** Wrap a plain object result into MCP CallToolResult format */
|
||||
function toToolResult(data: Record<string, any>) {
|
||||
const isError = 'error' in data;
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }],
|
||||
...(isError && { isError: true }),
|
||||
};
|
||||
}
|
||||
|
||||
const SlugSchema = { slug: z.string().describe('Business slug identifier (e.g., "frisco-german-auto")') };
|
||||
|
||||
export function createMCPServer() {
|
||||
const server = new McpServer({
|
||||
name: 'geolocal.io',
|
||||
version: '0.1.0',
|
||||
});
|
||||
|
||||
server.tool('get_business_info', SlugSchema, async (args) => {
|
||||
const result = await getBusinessInfo(args);
|
||||
return toToolResult(result);
|
||||
});
|
||||
|
||||
server.tool('get_hours', SlugSchema, async (args) => {
|
||||
const result = await getHours(args);
|
||||
return toToolResult(result);
|
||||
});
|
||||
|
||||
server.tool('get_services', SlugSchema, async (args) => {
|
||||
const result = await getServices(args);
|
||||
return toToolResult(result);
|
||||
});
|
||||
|
||||
server.tool('get_booking_link', SlugSchema, async (args) => {
|
||||
const result = await getBookingLink(args);
|
||||
return toToolResult(result);
|
||||
});
|
||||
|
||||
server.tool('get_related_businesses', SlugSchema, async (args) => {
|
||||
const result = await getRelatedBusinesses(args);
|
||||
return toToolResult(result);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
|
||||
// Multi-tenant MCP gateway — routes requests to the correct business
|
||||
// MVP: single MCP server serves all businesses
|
||||
// Future: route to per-tenant MCP instances or regional shards
|
||||
export function createGateway(server: McpServer) {
|
||||
// The McpServer handles its own request routing via the transport layer.
|
||||
// This gateway is a placeholder for future multi-tenant routing.
|
||||
return { server };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { pool } from '../db/schema.js';
|
||||
|
||||
export async function getBookingLink(params: { slug: string }): Promise<Record<string, any>> {
|
||||
const result = await pool.query(
|
||||
`SELECT calcom_link, name FROM businesses WHERE slug = $1`,
|
||||
[params.slug]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return { error: `Business '${params.slug}' not found` };
|
||||
}
|
||||
|
||||
const b = result.rows[0];
|
||||
return {
|
||||
business: b.name,
|
||||
booking_url: b.calcom_link,
|
||||
message: `Book an appointment with ${b.name} via Cal.com`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { pool } from '../db/schema.js';
|
||||
|
||||
export async function getBusinessInfo(params: { slug: string }): Promise<Record<string, any>> {
|
||||
const result = await pool.query(
|
||||
`SELECT name, category, address, city, state, zip, phone, website, story, owner_bio, photos
|
||||
FROM businesses WHERE slug = $1`,
|
||||
[params.slug]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return { error: `Business '${params.slug}' not found` };
|
||||
}
|
||||
|
||||
const b = result.rows[0];
|
||||
return {
|
||||
name: b.name,
|
||||
category: b.category,
|
||||
location: { address: b.address, city: b.city, state: b.state, zip: b.zip },
|
||||
phone: b.phone,
|
||||
website: b.website,
|
||||
story: b.story,
|
||||
owner_bio: b.owner_bio,
|
||||
photos: JSON.parse(b.photos),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { pool } from '../db/schema.js';
|
||||
|
||||
export async function getHours(params: { slug: string }): Promise<Record<string, any>> {
|
||||
const result = await pool.query(
|
||||
`SELECT hours FROM businesses WHERE slug = $1`,
|
||||
[params.slug]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return { error: `Business '${params.slug}' not found` };
|
||||
}
|
||||
|
||||
return { hours: JSON.parse(result.rows[0].hours) };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { pool } from '../db/schema.js';
|
||||
|
||||
export async function getRelatedBusinesses(params: { slug: string }): Promise<Record<string, any>> {
|
||||
const result = await pool.query(
|
||||
`SELECT id, slug, name, category, city, state, story, related_business_ids
|
||||
FROM businesses
|
||||
WHERE slug = $1`,
|
||||
[params.slug]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return { error: `Business '${params.slug}' not found` };
|
||||
}
|
||||
|
||||
const business = result.rows[0];
|
||||
const relatedIds = JSON.parse(business.related_business_ids || '[]');
|
||||
|
||||
if (relatedIds.length === 0) {
|
||||
// Fallback: same category, same city
|
||||
const fallback = await pool.query(
|
||||
`SELECT slug, name, category, city, state, story
|
||||
FROM businesses
|
||||
WHERE category = $1 AND city = $2 AND slug != $3
|
||||
LIMIT 5`,
|
||||
[business.category, business.city, params.slug]
|
||||
);
|
||||
|
||||
return {
|
||||
source_business: business.name,
|
||||
related: fallback.rows.map((r: any) => ({
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
category: r.category,
|
||||
location: `${r.city}, ${r.state}`,
|
||||
story: r.story,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve related business IDs
|
||||
const relatedResult = await pool.query(
|
||||
`SELECT slug, name, category, city, state, story
|
||||
FROM businesses
|
||||
WHERE id = ANY($1)`,
|
||||
[relatedIds]
|
||||
);
|
||||
|
||||
return {
|
||||
source_business: business.name,
|
||||
related: relatedResult.rows.map((r: any) => ({
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
category: r.category,
|
||||
location: `${r.city}, ${r.state}`,
|
||||
story: r.story,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { pool } from '../db/schema.js';
|
||||
|
||||
export async function getServices(params: { slug: string }): Promise<Record<string, any>> {
|
||||
const result = await pool.query(
|
||||
`SELECT services FROM businesses WHERE slug = $1`,
|
||||
[params.slug]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return { error: `Business '${params.slug}' not found` };
|
||||
}
|
||||
|
||||
return { services: JSON.parse(result.rows[0].services) };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user