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:
Ty
2026-07-16 14:10:21 -07:00
parent 4def4d0726
commit 2be10ce42c
22 changed files with 3419 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log
+12
View File
@@ -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"]
+2456
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+14
View File
@@ -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
}
}
+6
View File
@@ -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 || '',
};
+37
View File
@@ -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 };
+36
View File
@@ -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);
+26
View File
@@ -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);
});
+71
View File
@@ -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);
}
+52
View File
@@ -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;
}
+10
View File
@@ -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 };
}
+19
View File
@@ -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`,
};
}
+25
View File
@@ -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),
};
}
+14
View File
@@ -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) };
}
+58
View File
@@ -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,
})),
};
}
+14
View File
@@ -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) };
}
+17
View File
@@ -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"]
}