Add multi-account OAuth, Obsidian integration, product assets, and test tooling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-04-29 09:52:53 -04:00
parent 166f5d55a6
commit e3a272c332
67 changed files with 6204 additions and 94 deletions

106
product/README.md Normal file
View File

@@ -0,0 +1,106 @@
# SquareMCP
SquareMCP is the productization path for Hermes: a managed MCP gateway for internal tools.
## Positioning
Expose internal tools to AI agents with:
1. authentication
2. tool permissions
3. audit logs
4. observability
5. managed hosting
The product is aimed at teams building internal AI copilots that need speed, control, and governance.
## Offer
### Core product
SquareMCP, a managed MCP gateway for internal tools.
### Primary buyer
Teams building internal support, operations, and workflow copilots.
### Why they buy
1. safer access to internal systems
2. faster deployment of agent tooling
3. auditability for regulated or high-trust environments
## Packaging
### Free
1. 1 workspace
2. 2 connectors
3. limited monthly tool calls
4. community support
### Team
Price: $199 to $499 per month
1. 10 connectors
2. role based permissions
3. audit logs
4. retries and rate limits
5. email support
### Business
Price: $1,500 to $3,000 per month
1. SSO
2. private networking
3. longer log retention
4. alerts
5. SLA
6. advanced observability
### Enterprise
Price: $20k to $100k+ per year
1. VPC or on prem deployment
2. compliance features
3. dedicated support
4. custom connectors
5. architecture review
## Revenue model
1. subscription
2. setup fee
3. usage
Recommended starting offer:
1. $5k to $10k setup
2. $500 to $3k monthly
## 30-day launch sequence
1. Ship the landing page with "Book a pilot" CTA
2. Write and post the Show HN launch
3. Post the short pitch on LinkedIn the same week
4. Schedule Product Hunt after initial social proof
## Repo layout
- `incubation/`: product strategy and go-to-market notes
- `site/`: landing page scaffold
## Verification
Run the live site verification suite with:
1. `npm run test:product-site:verify`
2. `npm run deploy:product-site:verify`
3. `npm run test:product-site:cleanup`
Verification notes live in:
- `site/VERIFICATION.md`

View File

@@ -0,0 +1,71 @@
# Render the SquareMCP launch video locally
## Files
- `render_squaremcp_video.py` renders the video
- `squaremcp_shotlist.json` drives scene timing and copy
- `squaremcp_launch_captions.srt` provides burned-in captions
- `squaremcp_visual_prompts.md` can be used to create optional scene background images
## 1. Install dependencies
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
## 2. Basic render
Place the script in the same folder as the shotlist and captions, then run:
```bash
python render_squaremcp_video.py \
--shotlist squaremcp_shotlist.json \
--captions squaremcp_launch_captions.srt \
--output squaremcp_launch.mp4
```
## 3. Faster draft render
```bash
python render_squaremcp_video.py \
--shotlist squaremcp_shotlist.json \
--captions squaremcp_launch_captions.srt \
--output squaremcp_launch_draft.mp4 \
--draft
```
## 4. Add your own voiceover
```bash
python render_squaremcp_video.py \
--shotlist squaremcp_shotlist.json \
--captions squaremcp_launch_captions.srt \
--voiceover founder_voiceover.wav \
--output squaremcp_launch_with_vo.mp4
```
## 5. Optional richer visuals
If you generate background stills for each scene from the prompts, save them in an `assets/` folder like this:
- `assets/scene1.png`
- `assets/scene2.png`
- ...
- `assets/scene7.png`
Then run:
```bash
python render_squaremcp_video.py \
--shotlist squaremcp_shotlist.json \
--captions squaremcp_launch_captions.srt \
--assets-dir assets \
--output squaremcp_launch_with_assets.mp4
```
## Notes
- Default output is vertical 1080x1920
- `--draft` renders 720x1280 and is much faster
- The renderer creates a clean product-style motion graphic even without scene images
- If you already have a polished voiceover, attach it with `--voiceover`

View File

@@ -0,0 +1,481 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
import re
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import List, Tuple, Optional
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter
try:
from moviepy.editor import VideoClip, concatenate_videoclips, AudioFileClip
except Exception as exc: # pragma: no cover
raise SystemExit(
"MoviePy is required. Install dependencies with: pip install -r requirements.txt\n"
f"Import error: {exc}"
)
BG = (10, 14, 22)
BG2 = (16, 22, 34)
TEXT = (244, 247, 251)
MUTED = (144, 156, 178)
ACCENT = (112, 166, 255)
ACCENT_2 = (107, 228, 183)
CARD = (20, 28, 44)
CARD_2 = (28, 38, 58)
STROKE = (44, 58, 86)
SHADOW = (0, 0, 0, 110)
@dataclass
class Scene:
scene: int
start_s: float
end_s: float
on_screen_text: str
voiceover: str
visual: str
@property
def duration(self) -> float:
return self.end_s - self.start_s
@dataclass
class Subtitle:
start_s: float
end_s: float
text: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Render the SquareMCP launch video locally from the generated shot list and captions."
)
parser.add_argument("--shotlist", default="squaremcp_shotlist.json", help="Path to shot list JSON")
parser.add_argument("--captions", default="squaremcp_launch_captions.srt", help="Path to captions SRT")
parser.add_argument("--output", default="squaremcp_launch.mp4", help="Output MP4 path")
parser.add_argument("--voiceover", default=None, help="Optional voiceover audio file")
parser.add_argument("--assets-dir", default=None, help="Optional directory containing scene1.png ... scene7.png")
parser.add_argument("--width", type=int, default=1080, help="Video width")
parser.add_argument("--height", type=int, default=1920, help="Video height")
parser.add_argument("--fps", type=int, default=24, help="Frames per second")
parser.add_argument("--no-captions", action="store_true", help="Disable burned-in captions")
parser.add_argument("--draft", action="store_true", help="Lower-resolution faster render")
return parser.parse_args()
def load_scenes(path: Path) -> List[Scene]:
data = json.loads(path.read_text(encoding="utf-8"))
return [Scene(**item) for item in data]
_TIME_RE = re.compile(
r"(?P<h>\d\d):(?P<m>\d\d):(?P<s>\d\d),(?P<ms>\d\d\d)\s+-->\s+"
r"(?P<h2>\d\d):(?P<m2>\d\d):(?P<s2>\d\d),(?P<ms2>\d\d\d)"
)
def ts_to_seconds(h: str, m: str, s: str, ms: str) -> float:
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
def parse_srt(path: Optional[Path]) -> List[Subtitle]:
if not path or not path.exists():
return []
blocks = re.split(r"\n\s*\n", path.read_text(encoding="utf-8").strip())
subtitles: List[Subtitle] = []
for block in blocks:
lines = [ln.rstrip() for ln in block.splitlines() if ln.strip()]
if len(lines) < 2:
continue
timing_line = lines[1] if lines[0].strip().isdigit() else lines[0]
match = _TIME_RE.match(timing_line)
if not match:
continue
start = ts_to_seconds(match["h"], match["m"], match["s"], match["ms"])
end = ts_to_seconds(match["h2"], match["m2"], match["s2"], match["ms2"])
text_lines = lines[2:] if lines[0].strip().isdigit() else lines[1:]
subtitles.append(Subtitle(start, end, "\n".join(text_lines)))
return subtitles
def ease_in_out(x: float) -> float:
x = max(0.0, min(1.0, x))
return x * x * (3 - 2 * x)
def lerp(a: float, b: float, x: float) -> float:
return a + (b - a) * x
def get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
candidates = []
if bold:
candidates.extend([
"DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"Arial Bold.ttf",
"Arial.ttf",
])
else:
candidates.extend([
"DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"Arial.ttf",
])
for cand in candidates:
try:
return ImageFont.truetype(cand, size=size)
except Exception:
pass
return ImageFont.load_default()
def text_size(draw: ImageDraw.ImageDraw, text: str, font) -> Tuple[int, int]:
bbox = draw.multiline_textbbox((0, 0), text, font=font, spacing=10)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
def wrap_text(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> str:
parts = re.split(r"\n+", text)
wrapped_parts = []
for part in parts:
words = part.split()
if not words:
wrapped_parts.append("")
continue
lines = []
current = words[0]
for word in words[1:]:
candidate = f"{current} {word}"
w = draw.textbbox((0, 0), candidate, font=font)[2]
if w <= max_width:
current = candidate
else:
lines.append(current)
current = word
lines.append(current)
wrapped_parts.append("\n".join(lines))
return "\n".join(wrapped_parts)
def rounded_box(draw: ImageDraw.ImageDraw, box, fill, outline=None, radius=24, width=2):
draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)
def add_shadow(base: Image.Image, box, radius=28):
shadow = Image.new("RGBA", base.size, (0, 0, 0, 0))
sd = ImageDraw.Draw(shadow)
sd.rounded_rectangle(box, radius=28, fill=SHADOW)
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=radius))
base.alpha_composite(shadow)
def draw_gradient_background(img: Image.Image, t: float):
arr = np.zeros((img.height, img.width, 3), dtype=np.uint8)
y = np.linspace(0, 1, img.height)[:, None]
x = np.linspace(0, 1, img.width)[None, :]
pulse = 0.5 + 0.5 * math.sin(t * 0.55)
r = (BG[0] * (1 - y) + BG2[0] * y + 8 * pulse * x).clip(0, 255)
g = (BG[1] * (1 - y) + BG2[1] * y + 10 * pulse * x).clip(0, 255)
b = (BG[2] * (1 - y) + BG2[2] * y + 20 * pulse * x).clip(0, 255)
arr[:, :, 0] = r.astype(np.uint8)
arr[:, :, 1] = g.astype(np.uint8)
arr[:, :, 2] = b.astype(np.uint8)
bg = Image.fromarray(arr, mode="RGB").convert("RGBA")
img.alpha_composite(bg)
draw = ImageDraw.Draw(img)
step = max(40, img.width // 18)
grid_alpha = 30
for xx in range(0, img.width, step):
draw.line((xx, 0, xx, img.height), fill=(255, 255, 255, grid_alpha), width=1)
for yy in range(0, img.height, step):
draw.line((0, yy, img.width, yy), fill=(255, 255, 255, grid_alpha), width=1)
def paste_asset_background(img: Image.Image, asset_path: Optional[Path], t: float):
if not asset_path or not asset_path.exists():
return
asset = Image.open(asset_path).convert("RGBA")
scale = max(img.width / asset.width, img.height / asset.height) * 1.08
new_size = (int(asset.width * scale), int(asset.height * scale))
asset = asset.resize(new_size, Image.LANCZOS)
pan_x = int((asset.width - img.width) * (0.04 + 0.02 * math.sin(t * 0.3)))
pan_y = int((asset.height - img.height) * (0.04 + 0.02 * math.cos(t * 0.2)))
asset = asset.crop((pan_x, pan_y, pan_x + img.width, pan_y + img.height))
overlay = Image.new("RGBA", img.size, (5, 8, 14, 150))
img.alpha_composite(asset)
img.alpha_composite(overlay)
def draw_top_label(draw, width, height):
font = get_font(max(18, width // 42), bold=True)
label = "SQUAREMCP"
pad_x = width * 0.08
pad_y = height * 0.06
tw = draw.textbbox((0, 0), label, font=font)[2]
rounded_box(draw, (pad_x - 18, pad_y - 12, pad_x + tw + 18, pad_y + 42), fill=(18, 24, 38, 215), outline=(55, 75, 110), radius=18)
draw.text((pad_x, pad_y), label, font=font, fill=TEXT)
def draw_main_text(draw, width, height, text, progress):
font = get_font(max(34, width // 16), bold=True)
body_font = get_font(max(22, width // 32), bold=False)
max_width = int(width * 0.78)
wrapped = wrap_text(draw, text.replace(" | ", "\n"), font, max_width)
tw, th = text_size(draw, wrapped, font)
x = int(width * 0.08)
y = int(height * 0.18 + (1 - ease_in_out(min(progress * 1.6, 1.0))) * 40)
box = (x - 26, y - 22, x + tw + 28, y + th + 26)
draw.rounded_rectangle(box, radius=30, fill=(14, 18, 30, 185), outline=(52, 68, 98), width=2)
draw.multiline_text((x, y), wrapped, font=font, fill=TEXT, spacing=12)
return y + th + 30, body_font
def draw_footer_caption(draw, width, height, subtitles: List[Subtitle], current_t: float):
active = None
for sub in subtitles:
if sub.start_s <= current_t < sub.end_s:
active = sub.text
break
if not active:
return
font = get_font(max(24, width // 28), bold=True)
max_width = int(width * 0.78)
active = wrap_text(draw, active, font, max_width)
tw, th = text_size(draw, active, font)
x = (width - tw) // 2
y = int(height * 0.82)
pad = 22
draw.rounded_rectangle((x - pad, y - pad, x + tw + pad, y + th + pad), radius=26, fill=(8, 10, 16, 205), outline=(56, 72, 110), width=2)
draw.multiline_text((x, y), active, font=font, fill=TEXT, spacing=8, align="center")
def draw_node(draw, cx, cy, r, label, progress, active=False):
fill = ACCENT if active else CARD_2
outline = (160, 208, 255) if active else STROKE
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=fill, outline=outline, width=4)
font = get_font(max(22, int(r * 0.33)), bold=True)
bbox = draw.textbbox((0, 0), label, font=font)
draw.text((cx - (bbox[2] - bbox[0]) / 2, cy - (bbox[3] - bbox[1]) / 2), label, font=font, fill=TEXT)
def draw_scene_visual(scene: Scene, draw: ImageDraw.ImageDraw, img: Image.Image, local_t: float, progress: float, width: int, height: int):
if scene.scene == 1:
cy = int(height * 0.58)
x1, x2, x3 = int(width * 0.18), int(width * 0.5), int(width * 0.82)
draw_node(draw, x1, cy, int(width * 0.07), "Agent", progress, active=True)
rounded_box(draw, (x2 - 120, cy - 90, x2 + 120, cy + 90), fill=CARD, outline=(90, 116, 168), radius=30, width=3)
mid_font = get_font(max(28, width // 28), bold=True)
label = "Gateway"
bbox = draw.textbbox((0, 0), label, font=mid_font)
draw.text((x2 - (bbox[2] - bbox[0]) / 2, cy - 15), label, font=mid_font, fill=TEXT)
draw_node(draw, x3, cy, int(width * 0.07), "Tools", progress, active=False)
p = ease_in_out(min(local_t / max(scene.duration * 0.7, 0.001), 1.0))
stop_x = lerp(x1 + 76, x2 - 124, p)
draw.line((x1 + 78, cy, stop_x, cy), fill=ACCENT_2, width=10)
if p > 0.96:
draw.ellipse((stop_x - 10, cy - 10, stop_x + 10, cy + 10), fill=ACCENT_2)
elif scene.scene == 2:
cards = ["APIs", "Databases", "Workflows", "Ops Tools"]
positions = [(0.08, 0.56), (0.54, 0.56), (0.08, 0.75), (0.54, 0.75)]
for idx, (label, pos) in enumerate(zip(cards, positions), start=1):
delay = (idx - 1) * 0.12
p = ease_in_out((progress - delay) / 0.65)
if p <= 0:
continue
x = int(width * pos[0])
y = int(height * pos[1] + (1 - p) * 80)
w = int(width * 0.32)
h = int(height * 0.11)
add_shadow(img, (x, y, x + w, y + h), radius=18)
rounded_box(draw, (x, y, x + w, y + h), fill=CARD, outline=STROKE, radius=28)
f = get_font(max(26, width // 30), bold=True)
draw.text((x + 28, y + 26), label, font=f, fill=TEXT)
# Fake interface lines
for line_idx in range(3):
yy = y + 64 + line_idx * 14
draw.line((x + 28, yy, x + w - 28 - line_idx * 40, yy), fill=MUTED, width=4)
elif scene.scene == 3:
left = (int(width * 0.08), int(height * 0.52), int(width * 0.46), int(height * 0.76))
right = (int(width * 0.52), int(height * 0.52), int(width * 0.92), int(height * 0.76))
add_shadow(img, left, radius=20)
add_shadow(img, right, radius=20)
rounded_box(draw, left, fill=CARD, outline=STROKE, radius=28)
rounded_box(draw, right, fill=CARD, outline=STROKE, radius=28)
title_font = get_font(max(26, width // 28), bold=True)
small = get_font(max(20, width // 36), bold=False)
draw.text((left[0] + 26, left[1] + 22), "Policy Controls", font=title_font, fill=TEXT)
for i, label in enumerate(["OAuth required", "Scoped tools", "Write actions blocked"]):
yy = left[1] + 84 + i * 54
draw.rounded_rectangle((left[0] + 26, yy, left[0] + 74, yy + 28), radius=14, fill=(36, 56, 86), outline=(98, 132, 188))
knob_x = left[0] + 50 + (18 if i != 2 else 0)
draw.ellipse((knob_x, yy + 4, knob_x + 20, yy + 24), fill=ACCENT_2 if i != 2 else MUTED)
draw.text((left[0] + 92, yy - 2), label, font=small, fill=TEXT)
draw.text((right[0] + 26, right[1] + 22), "Audit Trail", font=title_font, fill=TEXT)
for i, item in enumerate(["tool.call github.list_prs", "auth.ok service_account", "policy.allow read_only"]):
yy = right[1] + 84 + i * 52
draw.rounded_rectangle((right[0] + 22, yy, right[2] - 22, yy + 36), radius=16, fill=CARD_2, outline=(60, 78, 116))
draw.text((right[0] + 36, yy + 6), item, font=small, fill=TEXT)
elif scene.scene == 4:
title = "SquareMCP"
subtitle = "Managed MCP gateway for internal tools"
title_font = get_font(max(56, width // 10), bold=True)
subtitle_font = get_font(max(28, width // 28), bold=False)
bbox = draw.textbbox((0, 0), title, font=title_font)
tw = bbox[2] - bbox[0]
draw.text(((width - tw) / 2, height * 0.52), title, font=title_font, fill=TEXT)
bbox2 = draw.textbbox((0, 0), subtitle, font=subtitle_font)
sw = bbox2[2] - bbox2[0]
draw.text(((width - sw) / 2, height * 0.52 + 120), subtitle, font=subtitle_font, fill=MUTED)
cy = int(height * 0.74)
x1, x2, x3 = int(width * 0.16), int(width * 0.5), int(width * 0.84)
draw_node(draw, x1, cy, int(width * 0.05), "Agent", progress, active=True)
rounded_box(draw, (x2 - 130, cy - 74, x2 + 130, cy + 74), fill=CARD, outline=(90, 116, 168), radius=26, width=3)
mid_font = get_font(max(24, width // 32), bold=True)
label = "SquareMCP"
bbox = draw.textbbox((0, 0), label, font=mid_font)
draw.text((x2 - (bbox[2] - bbox[0]) / 2, cy - 12), label, font=mid_font, fill=TEXT)
draw_node(draw, x3, cy, int(width * 0.05), "Tools", progress, active=False)
draw.line((x1 + 58, cy, x2 - 132, cy), fill=ACCENT_2, width=8)
draw.line((x2 + 132, cy, x3 - 58, cy), fill=ACCENT, width=8)
elif scene.scene == 5:
shell = (int(width * 0.08), int(height * 0.5), int(width * 0.92), int(height * 0.8))
add_shadow(img, shell, radius=24)
rounded_box(draw, shell, fill=(15, 21, 34), outline=(64, 85, 126), radius=34)
title_font = get_font(max(26, width // 28), bold=True)
small = get_font(max(20, width // 38), bold=False)
draw.text((shell[0] + 28, shell[1] + 22), "Gateway Runtime", font=title_font, fill=TEXT)
left_panel = (shell[0] + 28, shell[1] + 72, shell[0] + int(width * 0.28), shell[3] - 28)
mid_panel = (left_panel[2] + 22, shell[1] + 72, left_panel[2] + 22 + int(width * 0.22), shell[3] - 28)
right_panel = (mid_panel[2] + 22, shell[1] + 72, shell[2] - 28, shell[3] - 28)
for box, label in [(left_panel, "Connected Tools"), (mid_panel, "Access"), (right_panel, "Audit Log")]:
rounded_box(draw, box, fill=CARD, outline=STROKE, radius=24)
draw.text((box[0] + 18, box[1] + 14), label, font=small, fill=MUTED)
for i, tool in enumerate(["postgres.query", "github.repos", "zendesk.ticket"]):
yy = left_panel[1] + 52 + i * 58
draw.rounded_rectangle((left_panel[0] + 14, yy, left_panel[2] - 14, yy + 42), radius=16, fill=CARD_2, outline=(58, 76, 110))
draw.text((left_panel[0] + 28, yy + 10), tool, font=small, fill=TEXT)
for i, item in enumerate(["Auth OK", "Read only", "Tenant scoped"]):
yy = mid_panel[1] + 56 + i * 70
draw.rounded_rectangle((mid_panel[0] + 16, yy, mid_panel[2] - 16, yy + 48), radius=18, fill=(25, 37, 56), outline=(74, 96, 138))
draw.text((mid_panel[0] + 28, yy + 13), item, font=small, fill=ACCENT_2 if i == 0 else TEXT)
logs = [
"12:01 auth.ok",
"12:01 tool.call postgres.query",
"12:01 policy.allow",
"12:02 result.ok",
]
for i, log in enumerate(logs):
yy = right_panel[1] + 48 + i * 44
draw.text((right_panel[0] + 18, yy), log, font=small, fill=TEXT if i % 2 == 0 else MUTED)
draw.line((right_panel[0] + 18, yy + 28, right_panel[2] - 18, yy + 28), fill=(50, 66, 96), width=2)
elif scene.scene == 6:
titles = ["AI startups", "Copilot teams", "Regulated teams"]
subs = ["Move fast with guardrails", "Connect real internal systems", "Keep logs and policy boundaries"]
widths = [0.26, 0.26, 0.26]
start_x = 0.08
gap = 0.03
top = int(height * 0.56)
title_font = get_font(max(24, width // 34), bold=True)
body_font = get_font(max(20, width // 42), bold=False)
x = start_x
for idx, (title, sub, w_pct) in enumerate(zip(titles, subs, widths)):
p = ease_in_out((progress - idx * 0.12) / 0.7)
if p <= 0:
x += w_pct + gap
continue
box_w = int(width * w_pct)
box_h = int(height * 0.18)
xx = int(width * x)
yy = int(top + (1 - p) * 70)
add_shadow(img, (xx, yy, xx + box_w, yy + box_h), radius=18)
rounded_box(draw, (xx, yy, xx + box_w, yy + box_h), fill=CARD, outline=STROKE, radius=26)
draw.text((xx + 20, yy + 18), title, font=title_font, fill=TEXT)
wrapped = wrap_text(draw, sub, body_font, box_w - 40)
draw.multiline_text((xx + 20, yy + 66), wrapped, font=body_font, fill=MUTED, spacing=8)
x += w_pct + gap
elif scene.scene == 7:
title = "squaremcp.com"
font = get_font(max(52, width // 12), bold=True)
bbox = draw.textbbox((0, 0), title, font=font)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
x = (width - tw) / 2
y = height * 0.60
draw.rounded_rectangle((x - 30, y - 24, x + tw + 30, y + th + 24), radius=30, fill=(14, 18, 30, 190), outline=(70, 94, 140), width=2)
draw.text((x, y), title, font=font, fill=TEXT)
small = get_font(max(24, width // 32), bold=False)
msg = "Managed MCP gateway for internal tools"
bbox2 = draw.textbbox((0, 0), msg, font=small)
draw.text(((width - (bbox2[2] - bbox2[0])) / 2, y - 80), msg, font=small, fill=MUTED)
def make_scene_clip(scene: Scene, subtitles: List[Subtitle], width: int, height: int, assets_dir: Optional[Path], show_captions: bool) -> VideoClip:
asset_path = assets_dir / f"scene{scene.scene}.png" if assets_dir else None
def frame_fn(t: float):
local_t = max(0.0, min(scene.duration, t))
progress = local_t / max(scene.duration, 0.001)
img = Image.new("RGBA", (width, height), (0, 0, 0, 255))
draw_gradient_background(img, scene.start_s + local_t)
paste_asset_background(img, asset_path, scene.start_s + local_t)
draw = ImageDraw.Draw(img)
draw_top_label(draw, width, height)
draw_main_text(draw, width, height, scene.on_screen_text, progress)
draw_scene_visual(scene, draw, img, local_t, progress, width, height)
if show_captions:
draw_footer_caption(draw, width, height, subtitles, scene.start_s + local_t)
return np.array(img.convert("RGB"))
return VideoClip(frame_fn, duration=scene.duration)
def attach_audio(clip: VideoClip, voiceover_path: Optional[Path]) -> VideoClip:
if not voiceover_path:
return clip
audio = AudioFileClip(str(voiceover_path))
if audio.duration > clip.duration:
audio = audio.subclip(0, clip.duration)
return clip.set_audio(audio)
def main() -> None:
args = parse_args()
width = 720 if args.draft else args.width
height = 1280 if args.draft else args.height
shotlist_path = Path(args.shotlist)
captions_path = Path(args.captions) if args.captions else None
output_path = Path(args.output)
assets_dir = Path(args.assets_dir) if args.assets_dir else None
voiceover_path = Path(args.voiceover) if args.voiceover else None
scenes = load_scenes(shotlist_path)
subtitles = [] if args.no_captions else parse_srt(captions_path)
clips = [make_scene_clip(scene, subtitles, width, height, assets_dir, not args.no_captions) for scene in scenes]
final = concatenate_videoclips(clips, method="compose")
final = attach_audio(final, voiceover_path)
final.write_videofile(
str(output_path),
fps=args.fps,
codec="libx264",
audio_codec="aac" if voiceover_path else None,
preset="medium" if not args.draft else "ultrafast",
threads=4,
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,4 @@
moviepy==1.0.3
Pillow>=10,<12
numpy>=1.26,<3
imageio-ffmpeg>=0.4.9

View File

@@ -0,0 +1,33 @@
1
00:00:00,000 --> 00:00:04,000
Agents need access to internal tools.
Teams need control.
2
00:00:04,000 --> 00:00:09,000
The real value in agents starts when they can work inside real systems:
APIs, databases, workflows, and operational tools.
3
00:00:09,000 --> 00:00:15,000
But production access is not just connectivity.
You need authentication, scoped permissions, audit logs, and observability.
4
00:00:15,000 --> 00:00:21,000
That is why we built SquareMCP,
a managed MCP gateway for internal tools.
5
00:00:21,000 --> 00:00:31,000
SquareMCP gives teams a practical path to connect agents to internal systems
without stitching the security and control layer together by hand.
6
00:00:31,000 --> 00:00:38,000
We built it for AI startups, internal copilot teams,
and regulated teams that need more control than a quick demo stack can provide.
7
00:00:38,000 --> 00:00:42,000
Learn more at squaremcp.com.

View File

@@ -0,0 +1,33 @@
1
00:00:00,000 --> 00:00:04,000
Agents need access to internal tools.
Teams need control.
2
00:00:04,000 --> 00:00:09,000
The real value in agents starts when they can work inside real systems:
APIs, databases, workflows, and operational tools.
3
00:00:09,000 --> 00:00:15,000
But production access is not just connectivity.
You need authentication, scoped permissions, audit logs, and observability.
4
00:00:15,000 --> 00:00:21,000
That is why we built SquareMCP,
a managed MCP gateway for internal tools.
5
00:00:21,000 --> 00:00:31,000
SquareMCP gives teams a practical path to connect agents to internal systems
without stitching the security and control layer together by hand.
6
00:00:31,000 --> 00:00:38,000
We built it for AI startups, internal copilot teams,
and regulated teams that need more control than a quick demo stack can provide.
7
00:00:38,000 --> 00:00:42,000
Learn more at squaremcp.com.

View File

@@ -0,0 +1,58 @@
[
{
"scene": 1,
"start_s": 0.0,
"end_s": 4.0,
"on_screen_text": "Agents need access to internal tools. Teams need control.",
"voiceover": "Agents need access to internal tools. Teams need control.",
"visual": "Animated path between Agent and Internal Tools with connection paused at gateway boundary."
},
{
"scene": 2,
"start_s": 4.0,
"end_s": 9.0,
"on_screen_text": "APIs | Databases | Workflows | Operational systems",
"voiceover": "The real value in agents starts when they can work inside real systems: APIs, databases, workflows, and operational tools.",
"visual": "Quick UI sequence of API payload, SQL table, support dashboard, admin panel."
},
{
"scene": 3,
"start_s": 9.0,
"end_s": 15.0,
"on_screen_text": "Authentication | Permissions | Audit logs | Observability",
"voiceover": "But production access is not just connectivity. You need authentication, scoped permissions, audit logs, and observability.",
"visual": "Policy panel, auth controls, audit entries, request traces."
},
{
"scene": 4,
"start_s": 15.0,
"end_s": 21.0,
"on_screen_text": "SquareMCP \u2014 Managed MCP gateway for internal tools",
"voiceover": "That is why we built SquareMCP, a managed MCP gateway for internal tools.",
"visual": "Title card into gateway diagram."
},
{
"scene": 5,
"start_s": 21.0,
"end_s": 31.0,
"on_screen_text": "Secure agent access without stitching the control layer by hand",
"voiceover": "SquareMCP gives teams a practical path to connect agents to internal systems without stitching the security and control layer together by hand.",
"visual": "Gateway UI with connected tools, auth status, audit trail."
},
{
"scene": 6,
"start_s": 31.0,
"end_s": 38.0,
"on_screen_text": "Built for AI startups | Internal copilot teams | Regulated and fintech teams",
"voiceover": "We built it for AI startups, internal copilot teams, and regulated teams that need more control than a quick demo stack can provide.",
"visual": "Three use-case panels."
},
{
"scene": 7,
"start_s": 38.0,
"end_s": 42.0,
"on_screen_text": "squaremcp.com",
"voiceover": "Learn more at squaremcp.com.",
"visual": "Minimal end card."
}
]

View File

@@ -0,0 +1,58 @@
[
{
"scene": 1,
"start_s": 0.0,
"end_s": 4.0,
"on_screen_text": "Agents need access to internal tools. Teams need control.",
"voiceover": "Agents need access to internal tools. Teams need control.",
"visual": "Animated path between Agent and Internal Tools with connection paused at gateway boundary."
},
{
"scene": 2,
"start_s": 4.0,
"end_s": 9.0,
"on_screen_text": "APIs | Databases | Workflows | Operational systems",
"voiceover": "The real value in agents starts when they can work inside real systems: APIs, databases, workflows, and operational tools.",
"visual": "Quick UI sequence of API payload, SQL table, support dashboard, admin panel."
},
{
"scene": 3,
"start_s": 9.0,
"end_s": 15.0,
"on_screen_text": "Authentication | Permissions | Audit logs | Observability",
"voiceover": "But production access is not just connectivity. You need authentication, scoped permissions, audit logs, and observability.",
"visual": "Policy panel, auth controls, audit entries, request traces."
},
{
"scene": 4,
"start_s": 15.0,
"end_s": 21.0,
"on_screen_text": "SquareMCP \u2014 Managed MCP gateway for internal tools",
"voiceover": "That is why we built SquareMCP, a managed MCP gateway for internal tools.",
"visual": "Title card into gateway diagram."
},
{
"scene": 5,
"start_s": 21.0,
"end_s": 31.0,
"on_screen_text": "Secure agent access without stitching the control layer by hand",
"voiceover": "SquareMCP gives teams a practical path to connect agents to internal systems without stitching the security and control layer together by hand.",
"visual": "Gateway UI with connected tools, auth status, audit trail."
},
{
"scene": 6,
"start_s": 31.0,
"end_s": 38.0,
"on_screen_text": "Built for AI startups | Internal copilot teams | Regulated and fintech teams",
"voiceover": "We built it for AI startups, internal copilot teams, and regulated teams that need more control than a quick demo stack can provide.",
"visual": "Three use-case panels."
},
{
"scene": 7,
"start_s": 38.0,
"end_s": 42.0,
"on_screen_text": "squaremcp.com",
"voiceover": "Learn more at squaremcp.com.",
"visual": "Minimal end card."
}
]

View File

@@ -0,0 +1,58 @@
[
{
"scene": 1,
"start_s": 0.0,
"end_s": 4.0,
"on_screen_text": "Agents need access to internal tools. Teams need control.",
"voiceover": "Agents need access to internal tools. Teams need control.",
"visual": "Animated path between Agent and Internal Tools with connection paused at gateway boundary."
},
{
"scene": 2,
"start_s": 4.0,
"end_s": 9.0,
"on_screen_text": "APIs | Databases | Workflows | Operational systems",
"voiceover": "The real value in agents starts when they can work inside real systems: APIs, databases, workflows, and operational tools.",
"visual": "Quick UI sequence of API payload, SQL table, support dashboard, admin panel."
},
{
"scene": 3,
"start_s": 9.0,
"end_s": 15.0,
"on_screen_text": "Authentication | Permissions | Audit logs | Observability",
"voiceover": "But production access is not just connectivity. You need authentication, scoped permissions, audit logs, and observability.",
"visual": "Policy panel, auth controls, audit entries, request traces."
},
{
"scene": 4,
"start_s": 15.0,
"end_s": 21.0,
"on_screen_text": "SquareMCP \u2014 Managed MCP gateway for internal tools",
"voiceover": "That is why we built SquareMCP, a managed MCP gateway for internal tools.",
"visual": "Title card into gateway diagram."
},
{
"scene": 5,
"start_s": 21.0,
"end_s": 31.0,
"on_screen_text": "Secure agent access without stitching the control layer by hand",
"voiceover": "SquareMCP gives teams a practical path to connect agents to internal systems without stitching the security and control layer together by hand.",
"visual": "Gateway UI with connected tools, auth status, audit trail."
},
{
"scene": 6,
"start_s": 31.0,
"end_s": 38.0,
"on_screen_text": "Built for AI startups | Internal copilot teams | Regulated and fintech teams",
"voiceover": "We built it for AI startups, internal copilot teams, and regulated teams that need more control than a quick demo stack can provide.",
"visual": "Three use-case panels."
},
{
"scene": 7,
"start_s": 38.0,
"end_s": 42.0,
"on_screen_text": "squaremcp.com",
"voiceover": "Learn more at squaremcp.com.",
"visual": "Minimal end card."
}
]

View File

@@ -0,0 +1,18 @@
# SquareMCP Visual Prompts
Use these prompts in your preferred video or image tool to generate background clips or keyframes.
## Prompt 1
Minimal dark UI animation showing an AI agent connecting to internal company tools through a secure gateway, modern enterprise software aesthetic, subtle motion, clean typography areas, terminal plus dashboard fragments, no neon, no hype, realistic product style
## Prompt 2
Enterprise admin panel with authentication status, scoped permissions, audit logs, and observability charts, tasteful dark interface, believable SaaS product design, technical and restrained
## Prompt 3
Abstract network diagram showing agent, gateway, internal APIs, database, workflow engine, and support tool, thin lines, soft motion, clean modern UI, suitable for a product launch video
## Prompt 4
Close up product dashboard with tool call logs and policy controls, realistic trace view, developer infrastructure product, crisp interface, understated and credible
## Prompt 5
Simple branded end card for SquareMCP with just the product name and squaremcp.com, minimal dark background, refined motion design, founder-led launch feel

View File

@@ -0,0 +1,18 @@
# SquareMCP Visual Prompts
Use these prompts in your preferred video or image tool to generate background clips or keyframes.
## Prompt 1
Minimal dark UI animation showing an AI agent connecting to internal company tools through a secure gateway, modern enterprise software aesthetic, subtle motion, clean typography areas, terminal plus dashboard fragments, no neon, no hype, realistic product style
## Prompt 2
Enterprise admin panel with authentication status, scoped permissions, audit logs, and observability charts, tasteful dark interface, believable SaaS product design, technical and restrained
## Prompt 3
Abstract network diagram showing agent, gateway, internal APIs, database, workflow engine, and support tool, thin lines, soft motion, clean modern UI, suitable for a product launch video
## Prompt 4
Close up product dashboard with tool call logs and policy controls, realistic trace view, developer infrastructure product, crisp interface, understated and credible
## Prompt 5
Simple branded end card for SquareMCP with just the product name and squaremcp.com, minimal dark background, refined motion design, founder-led launch feel

35
product/animateit.py Normal file
View File

@@ -0,0 +1,35 @@
from PIL import Image
src = "squaremcp_storyboard.png" # save the generated grid image with this name
out = "squaremcp_launch.gif"
poster_out = "squaremcp_launch_poster.png"
img = Image.open(src).convert("RGB")
w, h = img.size
# 2 rows x 4 columns
cols, rows = 4, 2
frame_w = w // cols
frame_h = h // rows
frames = []
for r in range(rows):
for c in range(cols):
left = c * frame_w
top = r * frame_h
right = left + frame_w
bottom = top + frame_h
frame = img.crop((left, top, right, bottom))
frames.append(frame)
frames[0].save(poster_out, format="PNG")
frames[0].save(
out,
save_all=True,
append_images=frames[1:],
duration=1200
)
print(f"Saved {out}")
print(f"Saved {poster_out}")

View File

@@ -0,0 +1,50 @@
# Hermes MCP - Where to Publish and Promote
## Launch platforms
### Product Hunt
Best for initial visibility and social proof. Time it for a Tuesday to Thursday launch.
### Hacker News - Show HN
Position it as:
Show HN: I built a managed MCP gateway for internal tools
Lead with the technical problem, not the marketing pitch.
### dev.to / Hashnode
Publish a deep technical post about MCP security, permissions, and internal tool access.
## Ongoing promotion channels
### LinkedIn
Primary channel for heads of AI, platform engineers, and CTOs at mid-market and fintech companies.
### X / Twitter
Engage with the MCP and devtools ecosystem.
### Discord and Slack communities
1. Latent Space Discord
2. AI Engineer community
3. Hugging Face Discord
4. relevant fintech and banking Slacks
## Content that converts
1. a demo video showing a real connector setup in under 10 minutes
2. a case study showing an internal copilot with audit logs
3. a technical blog post on MCP security and governance
## Recommended launch sequence
1. Ship the landing page with "Book a pilot" CTA
2. Write and post the Show HN post
3. Post on LinkedIn the same week with a shorter pitch
4. Schedule Product Hunt 2 to 3 weeks later once you have pilot social proof

View File

@@ -0,0 +1,200 @@
# Hermes MCP Productization and Monetization
## Positioning
What you are selling:
Secure MCP gateway for company tools.
Not an SDK. Not a framework.
Help teams expose internal APIs, databases, and workflows to AI agents with:
1. authentication
2. tool permissions
3. audit logs
4. observability
5. managed hosting
## First product
Build one paid product first:
### MCP Gateway
A hosted service where a customer can:
1. connect one internal system
2. expose a safe MCP endpoint
3. control which tools are visible
4. track every tool call
5. revoke access instantly
## First customer
Sell to one of these first:
1. AI startups that need internal tool access fast
2. mid-size companies building internal copilots
3. fintech or regulated teams that need logs and permissions
Best first wedge: internal support and operations copilots.
## Packaging
### Free
For hobby and evaluation.
Includes:
1. 1 workspace
2. 2 connectors
3. limited monthly tool calls
4. community support
### Team
Price: $199 to $499 per month
Includes:
1. 10 connectors
2. role based permissions
3. audit logs
4. retries and rate limits
5. email support
### Business
Price: $1,500 to $3,000 per month
Includes:
1. SSO
2. private networking
3. longer log retention
4. alerts
5. SLA
6. advanced observability
### Enterprise
Price: $20k to $100k+ per year
Includes:
1. VPC or on prem deployment
2. compliance features
3. dedicated support
4. custom connectors
5. architecture review
## Revenue model
Use subscription + implementation + usage.
### Subscription
Recurring monthly or annual revenue for access to the platform.
### Setup fee
Charge $3k to $15k for initial deployment, connector setup, and policy configuration.
### Usage
Meter one of these:
1. tool calls
2. active connectors
3. active environments
4. audit log retention
## What to build in v1
### Must have
1. API key and OAuth auth
2. tool allowlist and denylist
3. request logging
4. connector for REST APIs
5. connector for Postgres
6. basic admin UI
7. tenant isolation
### Nice later
1. connector marketplace
2. workflow builder
3. prompt management
4. model routing
5. agent orchestration
## How to sell it
Pitch:
> Connect your internal tools to AI agents safely in a day, with full control and auditability.
The buyer cares about risk, speed, and control.
## 30-day monetization plan
### Week 1
Create:
1. landing page
2. demo video
3. one clear use case
### Week 2
Build:
1. hosted MCP endpoint
2. auth
3. tool filtering
4. logs
### Week 3
Outbound to:
1. founders building AI products
2. devtools teams
3. heads of AI or platform engineering
Offer a paid pilot.
Price: $5k setup + $500 to $2k per month
### Week 4
Close 1 to 3 pilots and learn:
1. what connector they want first
2. what security feature blocks the sale
3. what outcome they pay for
## Homepage copy
### Headline
Secure MCP infrastructure for enterprise AI
### Subheadline
Expose internal tools to agents with authentication, permissions, audit logs, and observability built in.
### CTA
Book a pilot
## Recommended path
Start with:
Managed MCP Gateway for internal tools

9
product/site/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM nginx:1.27-alpine
COPY product/site/nginx-site.conf /etc/nginx/conf.d/default.conf
COPY product/site/index.html /usr/share/nginx/html/index.html
COPY product/site/styles.css /usr/share/nginx/html/styles.css
COPY product/site/script.js /usr/share/nginx/html/script.js
COPY product/site/squaremcp_launch.gif /usr/share/nginx/html/squaremcp_launch.gif
COPY product/site/squaremcp_launch_poster.png /usr/share/nginx/html/squaremcp_launch_poster.png
COPY product/site/privacy.html /usr/share/nginx/html/privacy.html

View File

@@ -0,0 +1,64 @@
# SquareMCP Site Verification
Use this command after deployment changes to verify the live marketing site:
```bash
npm run test:product-site:verify
```
Or use the full deploy wrapper:
```bash
npm run deploy:product-site:verify
```
What it covers:
1. `smoke`
- verifies page JS behavior in isolation
- checks submit flow wiring, copy behavior, and hero poster swap logic
2. `e2e-desktop`
- loads the live site in Chromium
- checks navigation anchors
- waits for the hero animation to swap to the poster
- submits the live pilot request form
- verifies success UI and form reset
- confirms the request ID is written to the vault
3. `e2e-mobile`
- repeats the live submit flow in a mobile viewport
- checks for horizontal overflow and clipped key layout regions
- confirms the request ID is written to the vault
- compares the captured mobile screenshot against the stored baseline
Visual diff baselines:
- `product/site/baselines/desktop.png`
- `product/site/baselines/mobile.png`
Diff artifacts:
- `/tmp/squaremcp-e2e-desktop-diff.png`
- `/tmp/squaremcp-e2e-mobile-diff.png`
Artifacts:
- desktop screenshot: `/tmp/squaremcp-e2e-desktop.png`
- mobile screenshot: `/tmp/squaremcp-e2e-mobile.png`
Vault write targets:
- `SquareMCP/Pilot Requests.md`
- `Daily Notes/<today>.md`
Automated browser test submissions are tagged for cleanup:
- `#squaremcp-e2e`
- `#squaremcp-test-cleanup`
To remove those entries from the vault logs:
```bash
npm run test:product-site:cleanup
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

View File

@@ -0,0 +1,47 @@
import fs from "fs";
import path from "path";
const vaultRoot = process.env.OBSIDIAN_VAULT_PATH || "/home/garfield/obsidian/vaults";
const cleanupTag = "#squaremcp-test-cleanup";
const pilotLogPath = path.join(vaultRoot, "SquareMCP", "Pilot Requests.md");
const dailyNotePath = path.join(
vaultRoot,
"Daily Notes",
`${new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date())}.md`
);
function removeTaggedBlocks(content) {
const normalized = content.replace(/\r\n/g, "\n");
const blocks = normalized.split(/\n{2,}/);
const kept = blocks.filter((block) => !block.includes(cleanupTag));
return {
changed: kept.length !== blocks.length,
content: kept.join("\n\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n",
removedCount: blocks.length - kept.length,
};
}
function cleanFile(filePath) {
if (!fs.existsSync(filePath)) {
return { changed: false, removedCount: 0 };
}
const original = fs.readFileSync(filePath, "utf8");
const result = removeTaggedBlocks(original);
if (result.changed) {
fs.writeFileSync(filePath, result.content, "utf8");
}
return result;
}
const pilotLogResult = cleanFile(pilotLogPath);
const dailyLogResult = cleanFile(dailyNotePath);
console.log("squaremcp cleanup complete");
console.log(`pilot_log_removed: ${pilotLogResult.removedCount}`);
console.log(`daily_log_removed: ${dailyLogResult.removedCount}`);

View File

@@ -0,0 +1,53 @@
import fs from "fs";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
const actualPath = process.argv[2];
const baselinePath = process.argv[3];
const diffPath = process.argv[4] || "/tmp/squaremcp-visual-diff.png";
const threshold = Number(process.argv[5] || "0.02");
const maxDiffRatio = Number(process.argv[6] || "0.01");
if (!actualPath || !baselinePath) {
console.error("usage: node compare-screenshot.mjs <actual> <baseline> [diff] [threshold] [maxDiffRatio]");
process.exit(1);
}
if (!fs.existsSync(actualPath) || !fs.existsSync(baselinePath)) {
console.error("actual or baseline screenshot is missing");
process.exit(1);
}
const actual = PNG.sync.read(fs.readFileSync(actualPath));
const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
if (actual.width !== baseline.width || actual.height !== baseline.height) {
console.error(
`dimension mismatch: actual ${actual.width}x${actual.height}, baseline ${baseline.width}x${baseline.height}`
);
process.exit(1);
}
const diff = new PNG({ width: actual.width, height: actual.height });
const mismatchedPixels = pixelmatch(
actual.data,
baseline.data,
diff.data,
actual.width,
actual.height,
{ threshold }
);
const diffRatio = mismatchedPixels / (actual.width * actual.height);
fs.writeFileSync(diffPath, PNG.sync.write(diff));
if (diffRatio > maxDiffRatio) {
console.error(
`visual diff exceeded threshold: mismatched=${mismatchedPixels} ratio=${diffRatio.toFixed(6)} diff=${diffPath}`
);
process.exit(1);
}
console.log(
`visual diff PASS: mismatched=${mismatchedPixels} ratio=${diffRatio.toFixed(6)} diff=${diffPath}`
);

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
echo "==> build hermes"
docker build -t localhost:32000/hermes-mcp:latest -f Dockerfile .
echo "==> build site"
docker build --no-cache -t localhost:32000/squaremcp-site:latest -f product/site/Dockerfile .
echo "==> push hermes"
docker push localhost:32000/hermes-mcp:latest
echo "==> push site"
docker push localhost:32000/squaremcp-site:latest
echo "==> apply ingress"
microk8s kubectl apply -f product/site/squaremcp-k8s-ingress.yaml
echo "==> restart hermes"
microk8s kubectl rollout restart deployment/hermes-mcp -n fetcherpay
echo "==> restart site"
microk8s kubectl rollout restart deployment/squaremcp-site -n fetcherpay
echo "==> wait hermes"
microk8s kubectl rollout status deployment/hermes-mcp -n fetcherpay
echo "==> wait site"
microk8s kubectl rollout status deployment/squaremcp-site -n fetcherpay
echo "==> verify"
npm run test:product-site:verify

257
product/site/e2e-test.mjs Normal file
View File

@@ -0,0 +1,257 @@
import fs from "fs";
import path from "path";
import { spawnSync } from "child_process";
import { chromium } from "playwright";
const baseUrl = process.env.SQUAREMCP_BASE_URL || "https://squaremcp.com";
const profile = process.env.SQUAREMCP_E2E_PROFILE || "desktop";
const screenshotPath =
process.env.SQUAREMCP_E2E_SCREENSHOT || `/tmp/squaremcp-e2e-${profile}.png`;
const baselinePath =
process.env.SQUAREMCP_E2E_BASELINE ||
path.join(process.cwd(), "product/site/baselines", `${profile}.png`);
const diffPath =
process.env.SQUAREMCP_E2E_DIFF || `/tmp/squaremcp-e2e-${profile}-diff.png`;
const vaultRoot = process.env.OBSIDIAN_VAULT_PATH || "/home/garfield/obsidian/vaults";
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function uniqueEmail() {
const stamp = Date.now();
return `squaremcp-e2e-${profile}-${stamp}@example.com`;
}
function getProfileSettings() {
if (profile === "mobile") {
return {
viewport: { width: 390, height: 1180 },
isMobile: true,
hasTouch: true,
};
}
return {
viewport: { width: 1440, height: 1400 },
isMobile: false,
hasTouch: false,
};
}
async function runMobileLayoutChecks(page) {
const layout = await page.evaluate(() => {
const selectors = [
".topbar-row",
".topbar-actions",
"h1",
".lede",
".hero-points",
".actions",
".hero-panel",
"#pilot-form",
];
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const scrollWidth = document.documentElement.scrollWidth;
const issues = [];
for (const selector of selectors) {
const element = document.querySelector(selector);
if (!element) {
issues.push(`missing element: ${selector}`);
continue;
}
const rect = element.getBoundingClientRect();
if (rect.left < -1 || rect.right > viewportWidth + 1) {
issues.push(`horizontal overflow: ${selector}`);
}
if (rect.width <= 0 || rect.height <= 0) {
issues.push(`collapsed element: ${selector}`);
}
}
const actionButtons = Array.from(document.querySelectorAll(".actions .button"));
if (actionButtons.length >= 2) {
const first = actionButtons[0].getBoundingClientRect();
const second = actionButtons[1].getBoundingClientRect();
const overlap =
first.left < second.right &&
first.right > second.left &&
first.top < second.bottom &&
first.bottom > second.top;
if (overlap) {
issues.push("action buttons overlap");
}
}
return {
scrollWidth,
viewportWidth,
viewportHeight,
issues,
};
});
assert(
layout.scrollWidth <= layout.viewportWidth + 1,
`mobile horizontal overflow detected (${layout.scrollWidth} > ${layout.viewportWidth})`
);
assert(layout.issues.length === 0, `mobile layout issues: ${layout.issues.join(", ")}`);
}
function getEasternDateString() {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
async function waitForVaultWrite(requestId) {
const pilotLogPath = path.join(vaultRoot, "SquareMCP", "Pilot Requests.md");
const dailyNotePath = path.join(vaultRoot, "Daily Notes", `${getEasternDateString()}.md`);
const timeoutMs = 10000;
const intervalMs = 250;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const pilotLog = fs.existsSync(pilotLogPath) ? fs.readFileSync(pilotLogPath, "utf8") : "";
const dailyLog = fs.existsSync(dailyNotePath) ? fs.readFileSync(dailyNotePath, "utf8") : "";
if (pilotLog.includes(requestId) && dailyLog.includes(requestId)) {
return { pilotLogPath, dailyNotePath };
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`vault write not observed for request id ${requestId}`);
}
async function run() {
const browser = await chromium.launch({
headless: true,
args: [
"--host-resolver-rules=MAP squaremcp.com 104.190.60.129,MAP www.squaremcp.com 104.190.60.129",
],
});
const context = await browser.newContext(getProfileSettings());
const page = await context.newPage();
try {
const response = await page.goto(baseUrl, { waitUntil: "networkidle" });
assert(response && response.ok(), "page did not load successfully");
assert((await page.title()) === "SquareMCP", "page title mismatch");
await page.click('a.topbar-link[href="#pricing"]');
await page.waitForFunction(() => window.location.hash === "#pricing");
await page.click('a.topbar-link[href="#pilot-form"]');
await page.waitForFunction(() => window.location.hash === "#pilot-form");
if (profile === "mobile") {
await runMobileLayoutChecks(page);
}
const heroImage = page.locator("#heroAnimation");
await heroImage.waitFor();
const initialSrc = await heroImage.getAttribute("src");
assert(
initialSrc && initialSrc.includes("squaremcp_launch.gif"),
"hero image did not start with animated asset"
);
const playMs = Number((await heroImage.getAttribute("data-play-ms")) || "0");
assert(playMs > 0, "hero animation play duration missing");
await page.waitForTimeout(playMs + 750);
const finalSrc = await heroImage.getAttribute("src");
assert(
finalSrc && finalSrc.includes("squaremcp_launch_poster.png"),
"hero image did not swap to poster"
);
await page.screenshot({ path: screenshotPath, fullPage: true });
if (fs.existsSync(baselinePath)) {
const compare = spawnSync(
process.execPath,
["product/site/compare-screenshot.mjs", screenshotPath, baselinePath, diffPath, "0.02", "0.015"],
{ stdio: "inherit" }
);
assert(compare.status === 0, `visual diff failed for ${profile}`);
}
const email = uniqueEmail();
await page.fill('input[name="name"]', `SquareMCP ${profile.toUpperCase()} E2E`);
await page.fill('input[name="email"]', email);
await page.fill('input[name="company"]', `SquareMCP ${profile} Browser Test`);
await page.fill('input[name="role"]', "QA");
await page.evaluate(() => {
const tagField = document.querySelector('input[name="submission_tag"]');
if (tagField) {
tagField.value = "#squaremcp-e2e #squaremcp-test-cleanup";
}
});
await page.selectOption('select[name="use_case"]', { label: "Internal support copilot" });
await page.selectOption('select[name="timeline"]', { label: "Within 2 weeks" });
await page.fill('textarea[name="systems"]', "Postgres, internal REST APIs");
await page.fill('textarea[name="requirements"]', "Audit logs and SSO");
const submitResponsePromise = page.waitForResponse(
(resp) => resp.url().includes("/api/pilot-request") && resp.request().method() === "POST"
);
await page.click('button[type="submit"]');
const submitResponse = await submitResponsePromise;
assert(submitResponse.ok(), `pilot submit failed with status ${submitResponse.status()}`);
const submitJson = await submitResponse.json();
assert(submitJson.ok === true, "submit response missing ok=true");
assert(
typeof submitJson.request_id === "string" && submitJson.request_id.length > 0,
"submit response missing request id"
);
const outputText = await page.locator("#pilotOutput").textContent();
assert(
outputText && outputText.includes("Saved to SquareMCP intake successfully."),
"success message missing"
);
assert(outputText.includes(submitJson.request_id), "success message missing request id");
const nameValue = await page.locator('input[name="name"]').inputValue();
assert(nameValue === "", "form did not reset after submit");
const vaultTargets = await waitForVaultWrite(submitJson.request_id);
const pilotLog = fs.readFileSync(vaultTargets.pilotLogPath, "utf8");
const dailyLog = fs.readFileSync(vaultTargets.dailyNotePath, "utf8");
assert(
pilotLog.includes("#squaremcp-e2e #squaremcp-test-cleanup"),
"pilot log missing cleanup tag"
);
assert(
dailyLog.includes("#squaremcp-e2e #squaremcp-test-cleanup"),
"daily log missing cleanup tag"
);
console.log(`squaremcp product site e2e test (${profile}): PASS`);
console.log(`request_id: ${submitJson.request_id}`);
console.log(`email: ${email}`);
console.log(`screenshot: ${screenshotPath}`);
console.log(`baseline: ${baselinePath}`);
console.log(`diff: ${diffPath}`);
console.log(`pilot_log: ${vaultTargets.pilotLogPath}`);
console.log(`daily_log: ${vaultTargets.dailyNotePath}`);
} finally {
await context.close();
await browser.close();
}
}
run().catch((error) => {
console.error(`squaremcp product site e2e test (${profile}): FAIL: ${error.message}`);
process.exit(1);
});

303
product/site/index.html Normal file
View File

@@ -0,0 +1,303 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SquareMCP</title>
<meta
name="description"
content="SquareMCP is a managed MCP gateway for internal tools with authentication, permissions, audit logs, and observability."
/>
<link rel="stylesheet" href="./styles.css?v=20260424b" />
</head>
<body>
<nav class="topbar">
<div class="wrap topbar-row">
<a class="brand" href="/">
<span class="brand-mark">S</span>
<span class="brand-text">SquareMCP</span>
</a>
<div class="topbar-actions">
<a class="topbar-link" href="#pricing">Pricing</a>
<a class="topbar-link" href="#pilot-form">Pilot intake</a>
<a class="button secondary" href="mailto:info@squaremcp.com">Contact</a>
</div>
</div>
</nav>
<header class="hero">
<div class="wrap hero-grid">
<div class="hero-copy">
<div class="eyebrow">Managed MCP infrastructure</div>
<h1>SquareMCP for secure internal tool access</h1>
<p class="lede">
Let AI agents work with your internal systems without bypassing operating controls.
SquareMCP gives teams a managed MCP layer for authentication, permissions, audit
logs, and production visibility from day one.
</p>
<div class="hero-points">
<span>Fast pilot deployment</span>
<span>Tool-level controls</span>
<span>Audit-ready access</span>
</div>
<div class="hero-contact">
Contact:
<a href="mailto:info@squaremcp.com">info@squaremcp.com</a>
</div>
<div class="actions">
<a class="button primary" href="#pilot-form">Book a pilot</a>
<a class="button secondary" href="#pricing">View pricing</a>
</div>
</div>
<section class="hero-panel" aria-labelledby="pilot-preview-title">
<div class="hero-media">
<img
id="heroAnimation"
src="./squaremcp_launch.gif"
data-animated-src="./squaremcp_launch.gif"
data-poster-src="./squaremcp_launch_poster.png"
data-play-ms="9600"
alt="SquareMCP launch storyboard preview"
width="728"
height="410"
/>
</div>
<div class="panel-topline">Typical first deployment</div>
<h2 id="pilot-preview-title">Internal support copilot with safe system access</h2>
<ul class="signal-list">
<li>
<strong>Connectors:</strong>
REST APIs, Postgres, internal operations tools
</li>
<li>
<strong>Controls:</strong>
allowlists, deny lists, revocation, access boundaries
</li>
<li>
<strong>Visibility:</strong>
request logs, usage traces, deployment-level audit history
</li>
</ul>
<div class="metric-row">
<div class="metric">
<span class="metric-value">1 day</span>
<span class="metric-label">first safe endpoint</span>
</div>
<div class="metric">
<span class="metric-value">$5k+</span>
<span class="metric-label">pilot setup range</span>
</div>
</div>
</section>
</div>
</header>
<main>
<section class="band">
<div class="wrap section-head">
<div>
<div class="kicker">Why teams buy</div>
<h2>Put agent access behind a layer your team can operate</h2>
</div>
<p class="section-copy">
The buyer does not want another framework to assemble. They want a managed path to
exposing internal tools to agents without opening production systems up blindly.
</p>
</div>
<div class="wrap feature-grid">
<article class="feature">
<h3>Authentication</h3>
<p>Give agents access through API keys and OAuth flows that can be rotated and revoked.</p>
</article>
<article class="feature">
<h3>Tool permissions</h3>
<p>Expose only the tools a workspace should use, with visibility into who can call what.</p>
</article>
<article class="feature">
<h3>Audit logs</h3>
<p>Track every tool call so operators and regulated teams can review activity later.</p>
</article>
<article class="feature">
<h3>Observability</h3>
<p>See request volume, connector activity, and operational failures before pilots go sideways.</p>
</article>
</div>
</section>
<section class="band alt">
<div class="wrap section-head">
<div>
<div class="kicker">Ideal buyers</div>
<h2>Built for internal copilot teams first</h2>
</div>
<p class="section-copy">
The cleanest first wedge is internal support and operations copilots, where ROI is
easier to prove and governance matters early.
</p>
</div>
<div class="wrap buyer-grid">
<article class="buyer-card">
<h3>AI startups</h3>
<p>Need internal tool access quickly without building a full security layer from scratch.</p>
</article>
<article class="buyer-card">
<h3>Mid-market internal AI teams</h3>
<p>Need a controlled bridge between copilots and the systems employees already use.</p>
</article>
<article class="buyer-card">
<h3>Fintech and regulated teams</h3>
<p>Need logs, permissions, and operating discipline before exposing sensitive workflows.</p>
</article>
</div>
</section>
<section id="pricing" class="band">
<div class="wrap section-head">
<div>
<div class="kicker">Packaging</div>
<h2>Start with a paid pilot, then grow into recurring revenue</h2>
</div>
<p class="section-copy">
The early offer is a managed deployment, not a self-serve platform sale. Close pilots,
then learn which security and connector features unblock larger contracts.
</p>
</div>
<div class="wrap pricing-grid">
<article class="pricing-card">
<div class="pricing-tier">Pilot</div>
<div class="pricing-price">$5k-$10k setup</div>
<p class="pricing-copy">$500-$3k monthly once the initial deployment is live.</p>
<ul class="list">
<li>Hosted MCP endpoint</li>
<li>Auth and connector setup</li>
<li>Tool filtering and logs</li>
<li>Delivery support</li>
</ul>
</article>
<article class="pricing-card">
<div class="pricing-tier">Team</div>
<div class="pricing-price">$199-$499/mo</div>
<p class="pricing-copy">For smaller internal AI teams evaluating managed MCP access.</p>
<ul class="list">
<li>Up to 10 connectors</li>
<li>Role-based permissions</li>
<li>Audit logs</li>
<li>Email support</li>
</ul>
</article>
<article class="pricing-card">
<div class="pricing-tier">Business</div>
<div class="pricing-price">$1.5k-$3k/mo</div>
<p class="pricing-copy">For production deployments with stronger operating requirements.</p>
<ul class="list">
<li>SSO</li>
<li>Private networking</li>
<li>Longer retention</li>
<li>Alerts and SLA</li>
</ul>
</article>
</div>
</section>
<section id="pilot-form" class="band">
<div class="wrap form-grid">
<div>
<div class="kicker">SquareMCP pilot intake</div>
<h2>Collect a serious pilot request, not just an email click</h2>
<p class="section-copy">
This intake form builds a ready-to-send pilot request with the details you actually
need to qualify a first deployment for squaremcp.com.
</p>
</div>
<form class="pilot-form" id="pilotIntakeForm">
<input type="hidden" name="submission_tag" value="" />
<div class="field-grid">
<label>
<span>Name</span>
<input name="name" type="text" required />
</label>
<label>
<span>Work email</span>
<input name="email" type="email" required />
</label>
<label>
<span>Company</span>
<input name="company" type="text" required />
</label>
<label>
<span>Role</span>
<input name="role" type="text" required />
</label>
<label>
<span>Primary use case</span>
<select name="use_case" required>
<option value="">Select one</option>
<option>Internal support copilot</option>
<option>Operations workflow assistant</option>
<option>Internal developer tooling</option>
<option>Regulated knowledge access</option>
<option>Other</option>
</select>
</label>
<label>
<span>Expected timeline</span>
<select name="timeline" required>
<option value="">Select one</option>
<option>Within 2 weeks</option>
<option>This month</option>
<option>This quarter</option>
<option>Exploring only</option>
</select>
</label>
</div>
<label>
<span>Internal systems to connect</span>
<textarea
name="systems"
rows="4"
placeholder="Examples: Postgres, Zendesk, internal REST APIs, admin workflows"
required
></textarea>
</label>
<label>
<span>Security or compliance requirements</span>
<textarea
name="requirements"
rows="4"
placeholder="Examples: audit logs, SSO, network isolation, approval flows"
required
></textarea>
</label>
<div class="form-actions">
<button class="button primary" type="submit">Submit pilot request</button>
<button class="button secondary" type="button" id="copyRequestButton">Copy request</button>
</div>
<div class="form-output" id="pilotOutput" aria-live="polite">
Fill out the form to submit your SquareMCP pilot request.
</div>
</form>
</div>
</section>
</main>
<footer class="footer">
<div class="wrap footer-row">
<div>
<strong>SquareMCP</strong>
<span class="footer-copy">Managed MCP infrastructure for internal tools.</span>
</div>
<div class="footer-links">
<a class="footer-link" href="mailto:info@squaremcp.com">info@squaremcp.com</a>
<a class="footer-link" href="https://squaremcp.com">squaremcp.com</a>
</div>
</div>
</footer>
<script src="./script.js?v=20260424b"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
server {
listen 8080;
server_name squaremcp.com www.squaremcp.com;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location = /index.html {
add_header Cache-Control "no-cache";
}
location ~* \.(css|js)$ {
add_header Cache-Control "public, max-age=3600";
}
}

41
product/site/privacy.html Normal file
View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy — SquareMCP</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 680px; margin: 60px auto; padding: 0 24px; color: #111; line-height: 1.7; }
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
.sub { color: #666; font-size: 0.9rem; margin-bottom: 2rem; }
h2 { font-size: 1.05rem; margin-top: 2rem; }
a { color: #111; }
nav { margin-bottom: 2rem; font-size: 0.9rem; }
nav a { text-decoration: none; color: #666; }
nav a:hover { color: #111; }
</style>
</head>
<body>
<nav><a href="/">← squaremcp.com</a></nav>
<h1>Privacy Policy</h1>
<p class="sub">SquareMCP &mdash; Last updated April 28, 2026</p>
<h2>What SquareMCP is</h2>
<p>SquareMCP is a personal MCP server platform that connects AI assistants to your own tools — email, notes, and internal systems. It is operated by Garfield Heron and is currently in private pilot.</p>
<h2>Data we collect</h2>
<p>We collect only what is necessary to operate the service: your name, email address, company, and use case when you submit a pilot request. This information is stored securely and used only to evaluate and onboard pilot participants.</p>
<h2>Data we do not collect</h2>
<p>SquareMCP does not use analytics, advertising trackers, or third-party data sharing of any kind. We do not sell or rent your information.</p>
<h2>Your data and your tools</h2>
<p>When you use SquareMCP to connect your own accounts (email, notes, calendars), those connections are authenticated via OAuth and run entirely within your own environment. Your data does not pass through SquareMCP infrastructure — the MCP server runs on your own host or a host you control.</p>
<h2>OAuth tokens</h2>
<p>OAuth access tokens used to authenticate AI sessions are stored in a private database on your server. They expire after 24 hours and are never shared with third parties.</p>
<h2>Contact</h2>
<p>Questions or requests: <a href="mailto:garfield@fetcherpay.com">garfield@fetcherpay.com</a></p>
</body>
</html>

97
product/site/script.js Normal file
View File

@@ -0,0 +1,97 @@
const form = document.getElementById("pilotIntakeForm");
const output = document.getElementById("pilotOutput");
const copyButton = document.getElementById("copyRequestButton");
const heroAnimation = document.getElementById("heroAnimation");
const submitEndpoint = "/api/pilot-request";
if (heroAnimation) {
const posterSrc = heroAnimation.dataset.posterSrc;
const playMs = Number(heroAnimation.dataset.playMs || "0");
if (posterSrc && playMs > 0) {
window.setTimeout(() => {
heroAnimation.src = posterSrc;
}, playMs);
}
}
function buildMessage(data) {
return [
"Pilot request for SquareMCP",
"",
`Name: ${data.name}`,
`Email: ${data.email}`,
`Company: ${data.company}`,
`Role: ${data.role}`,
`Primary use case: ${data.use_case}`,
`Timeline: ${data.timeline}`,
"",
"Internal systems to connect:",
data.systems,
"",
"Security or compliance requirements:",
data.requirements,
].join("\n");
}
function getFormData() {
const formData = new FormData(form);
return Object.fromEntries(formData.entries());
}
function setOutput(message, isReady = false) {
output.textContent = message;
output.classList.toggle("ready", isReady);
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!form.reportValidity()) {
return;
}
const data = getFormData();
const message = buildMessage(data);
setOutput("Submitting your pilot request...", false);
try {
const response = await fetch(submitEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`submit failed (${response.status})`);
}
const result = await response.json();
setOutput(
`${message}\n\nSaved to SquareMCP intake successfully.${result.request_id ? `\nRequest ID: ${result.request_id}` : ""}`,
true
);
form.reset();
} catch {
setOutput(
`${message}\n\nSubmission failed. Copy the request below and send it to info@squaremcp.com manually.`,
true
);
}
});
copyButton.addEventListener("click", async () => {
if (!form.reportValidity()) {
return;
}
const message = buildMessage(getFormData());
try {
await navigator.clipboard.writeText(message);
setOutput(`${message}\n\nCopied to clipboard.`, true);
} catch {
setOutput(`${message}\n\nClipboard access failed. Copy the text manually.`, true);
}
});

68
product/site/server.mjs Normal file
View File

@@ -0,0 +1,68 @@
import http from "node:http";
import { createReadStream, existsSync } from "node:fs";
import { stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = __dirname;
const port = Number(process.env.PRODUCT_SITE_PORT || 4173);
const host = "127.0.0.1";
const contentTypes = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
};
function resolvePath(urlPath) {
const cleanPath = decodeURIComponent(urlPath.split("?")[0]);
const relativePath = cleanPath === "/" ? "/index.html" : cleanPath;
const absolutePath = path.normalize(path.join(root, relativePath));
if (!absolutePath.startsWith(root)) {
return null;
}
// Try with .html extension for clean URLs (e.g. /privacy → privacy.html)
const withHtml = absolutePath + ".html";
if (!existsSync(absolutePath) && existsSync(withHtml)) return withHtml;
return absolutePath;
}
const server = http.createServer(async (req, res) => {
const filePath = resolvePath(req.url || "/");
if (!filePath) {
res.writeHead(403);
res.end("Forbidden");
return;
}
try {
const fileStat = await stat(filePath);
if (!fileStat.isFile()) {
res.writeHead(404);
res.end("Not found");
return;
}
} catch {
const fallback = path.join(root, "index.html");
if (existsSync(fallback)) {
res.writeHead(200, { "Content-Type": contentTypes[".html"] });
createReadStream(fallback).pipe(res);
return;
}
res.writeHead(404);
res.end("Not found");
return;
}
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, {
"Content-Type": contentTypes[ext] || "application/octet-stream",
});
createReadStream(filePath).pipe(res);
});
server.listen(port, host, () => {
console.log(`SquareMCP product site running at http://${host}:${port}`);
});

208
product/site/smoke-test.cjs Normal file
View File

@@ -0,0 +1,208 @@
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const scriptPath = path.join(__dirname, "script.js");
const scriptSource = fs.readFileSync(scriptPath, "utf8");
function createClassList() {
const values = new Set();
return {
toggle(name, enabled) {
if (enabled) values.add(name);
else values.delete(name);
},
has(name) {
return values.has(name);
},
};
}
function createForm(valid, entries) {
const listeners = {};
let resetCount = 0;
return {
__entries: entries,
reportValidity() {
return valid;
},
reset() {
resetCount += 1;
},
get resetCount() {
return resetCount;
},
addEventListener(type, handler) {
listeners[type] = handler;
},
dispatch(type, event) {
if (!listeners[type]) throw new Error(`missing listener: ${type}`);
return listeners[type](event);
},
};
}
function createButton() {
const listeners = {};
return {
addEventListener(type, handler) {
listeners[type] = handler;
},
dispatch(type, event) {
if (!listeners[type]) throw new Error(`missing listener: ${type}`);
return listeners[type](event);
},
};
}
function createEnv({ valid = true, fetchOk = true, clipboardFails = false } = {}) {
const timers = [];
const output = { textContent: "", classList: createClassList() };
const heroAnimation = {
src: "./squaremcp_launch.gif",
dataset: {
posterSrc: "./squaremcp_launch_poster.png",
playMs: "9600",
},
};
const entries = [
["name", "Casey"],
["email", "casey@example.com"],
["company", "SquareMCP Labs"],
["role", "Founder"],
["use_case", "Internal support copilot"],
["timeline", "Within 2 weeks"],
["systems", "Postgres, internal REST APIs"],
["requirements", "Audit logs and SSO"],
];
const form = createForm(valid, entries);
const button = createButton();
const fetchCalls = [];
let clipboardText = null;
const context = {
console,
window: {
setTimeout(fn, ms) {
timers.push({ fn, ms });
return timers.length;
},
},
document: {
getElementById(id) {
if (id === "pilotIntakeForm") return form;
if (id === "pilotOutput") return output;
if (id === "copyRequestButton") return button;
if (id === "heroAnimation") return heroAnimation;
return null;
},
},
navigator: {
clipboard: {
async writeText(value) {
if (clipboardFails) throw new Error("clipboard denied");
clipboardText = value;
},
},
},
fetch: async (url, options) => {
fetchCalls.push({ url, options });
if (!fetchOk) {
return {
ok: false,
status: 500,
async json() {
return { error: "submit failed" };
},
};
}
return {
ok: true,
status: 201,
async json() {
return { ok: true, request_id: "req-123" };
},
};
},
FormData: class MockFormData {
constructor(target) {
this.target = target;
}
entries() {
return this.target.__entries[Symbol.iterator]();
}
[Symbol.iterator]() {
return this.entries();
}
},
};
vm.createContext(context);
vm.runInContext(scriptSource, context, { filename: "script.js" });
return {
form,
button,
output,
heroAnimation,
timers,
fetchCalls,
getClipboardText: () => clipboardText,
};
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function run() {
const validEnv = createEnv();
assert(validEnv.timers.length === 1, "hero animation timer missing");
assert(validEnv.timers[0].ms === 9600, "hero animation timer mismatch");
validEnv.timers[0].fn();
assert(validEnv.heroAnimation.src === "./squaremcp_launch_poster.png", "hero poster swap failed");
let prevented = false;
await validEnv.form.dispatch("submit", {
preventDefault() {
prevented = true;
},
});
assert(prevented, "submit did not prevent default");
assert(validEnv.fetchCalls.length === 1, "submit did not call fetch");
assert(validEnv.fetchCalls[0].url === "/api/pilot-request", "submit endpoint mismatch");
assert(validEnv.fetchCalls[0].options.method === "POST", "submit method mismatch");
const payload = JSON.parse(validEnv.fetchCalls[0].options.body);
assert(payload.company === "SquareMCP Labs", "submit payload mismatch");
assert(validEnv.form.resetCount === 1, "form did not reset after success");
assert(validEnv.output.textContent.includes("Saved to SquareMCP intake successfully."), "submit success message missing");
assert(validEnv.output.classList.has("ready"), "submit did not set ready class");
await validEnv.button.dispatch("click", {});
assert(validEnv.getClipboardText().includes("SquareMCP Labs"), "copy handler did not write clipboard");
assert(validEnv.output.textContent.includes("Copied to clipboard."), "copy success message missing");
const invalidEnv = createEnv({ valid: false });
await invalidEnv.form.dispatch("submit", { preventDefault() {} });
assert(invalidEnv.fetchCalls.length === 0, "invalid submit should not call fetch");
assert(invalidEnv.output.textContent === "", "invalid submit should not update output");
const failureEnv = createEnv({ fetchOk: false });
await failureEnv.form.dispatch("submit", { preventDefault() {} });
assert(failureEnv.output.textContent.includes("Submission failed."), "submit failure message missing");
assert(failureEnv.form.resetCount === 0, "failed submit should not reset form");
const clipboardFailureEnv = createEnv({ clipboardFails: true });
await clipboardFailureEnv.button.dispatch("click", {});
assert(
clipboardFailureEnv.output.textContent.includes("Clipboard access failed."),
"clipboard failure message missing"
);
console.log("squaremcp product site smoke test: PASS");
}
run().catch((error) => {
console.error(`squaremcp product site smoke test: FAIL: ${error.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,13 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: squaremcp-tls
namespace: fetcherpay
spec:
secretName: squaremcp-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- squaremcp.com
- www.squaremcp.com

View File

@@ -0,0 +1,97 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: squaremcp-site
namespace: fetcherpay
spec:
replicas: 1
selector:
matchLabels:
app: squaremcp-site
template:
metadata:
labels:
app: squaremcp-site
spec:
containers:
- name: squaremcp-site
image: localhost:32000/squaremcp-site:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: squaremcp-site
namespace: fetcherpay
spec:
selector:
app: squaremcp-site
ports:
- protocol: TCP
port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: squaremcp-site-ingress
namespace: fetcherpay
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-buffering: "off"
spec:
ingressClassName: nginx
rules:
- host: squaremcp.com
http:
paths:
- path: /api/pilot-request
pathType: Prefix
backend:
service:
name: hermes-mcp
port:
number: 3456
- path: /
pathType: Prefix
backend:
service:
name: squaremcp-site
port:
number: 80
- host: www.squaremcp.com
http:
paths:
- path: /api/pilot-request
pathType: Prefix
backend:
service:
name: hermes-mcp
port:
number: 3456
- path: /
pathType: Prefix
backend:
service:
name: squaremcp-site
port:
number: 80
tls:
- hosts:
- squaremcp.com
- www.squaremcp.com
secretName: squaremcp-tls

View File

@@ -0,0 +1,29 @@
# K8s Ingress for mail.squaremcp.com
# Routes webmail/admin traffic to the existing poste.io service in the email namespace
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: squaremcp-mail-ingress
namespace: email
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/ssl-redirect: "false"
spec:
ingressClassName: nginx
rules:
- host: mail.squaremcp.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: complete-mail-service
port:
number: 80
tls:
- hosts:
- mail.squaremcp.com
secretName: mail-squaremcp-tls

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

520
product/site/styles.css Normal file
View File

@@ -0,0 +1,520 @@
:root {
color-scheme: light;
--bg: #f3f6fb;
--surface: #ffffff;
--surface-alt: #e9effc;
--surface-strong: #dfe8fb;
--text: #132038;
--muted: #57657c;
--line: #cfd8ea;
--accent: #0e63f6;
--accent-strong: #0a49c2;
--accent-soft: #e3edff;
--success: #0e7a53;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: Inter, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a {
color: inherit;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid rgba(207, 216, 234, 0.7);
background: rgba(243, 246, 251, 0.92);
backdrop-filter: blur(12px);
}
.topbar-row {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
font-weight: 800;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--accent);
color: #fff;
}
.brand-text {
color: var(--text);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.topbar-link {
color: var(--muted);
text-decoration: none;
font-weight: 600;
}
.topbar-link:hover {
color: var(--accent-strong);
}
.wrap {
width: min(1160px, calc(100% - 32px));
margin: 0 auto;
}
.hero {
padding: 72px 0 52px;
background:
radial-gradient(circle at top right, #d9e6ff 0%, transparent 32%),
linear-gradient(180deg, #f8fbff 0%, #eaf1ff 100%);
border-bottom: 1px solid var(--line);
}
.hero-grid,
.feature-grid,
.buyer-grid,
.pricing-grid,
.launch-grid,
.form-grid,
.field-grid {
display: grid;
gap: 20px;
}
.hero-grid {
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 420px);
align-items: start;
}
.hero-copy {
padding-right: 8px;
}
.eyebrow,
.kicker,
.panel-topline,
.pricing-tier {
font-size: 13px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.eyebrow,
.kicker,
.panel-topline {
color: var(--accent-strong);
}
h1,
h2,
h3 {
margin: 0 0 12px;
}
h1 {
max-width: 760px;
font-size: 56px;
line-height: 1.02;
}
h2 {
font-size: 34px;
line-height: 1.1;
}
h3 {
font-size: 20px;
}
.lede,
.section-copy,
.pricing-copy,
.buyer-card p,
.feature p,
.launch-note p {
color: var(--muted);
}
.lede {
max-width: 760px;
font-size: 20px;
}
.hero-points {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 22px;
}
.hero-points span {
min-height: 36px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.8);
font-size: 14px;
color: var(--muted);
}
.hero-contact {
margin-top: 18px;
color: var(--muted);
}
.hero-contact a,
.footer-link {
color: var(--accent-strong);
text-decoration: none;
}
.hero-contact a:hover,
.footer-link:hover {
text-decoration: underline;
}
.actions,
.form-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.actions {
margin-top: 24px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 18px;
border: 1px solid transparent;
border-radius: 8px;
text-decoration: none;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.button.primary {
background: var(--accent);
color: #fff;
}
.button.primary:hover {
background: var(--accent-strong);
}
.button.secondary {
background: var(--surface);
color: var(--text);
border-color: var(--line);
}
.button.secondary:hover {
background: #f9fbff;
}
.hero-panel,
.feature,
.buyer-card,
.pricing-card,
.launch-note,
.pilot-form {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
}
.hero-panel {
padding: 22px;
box-shadow: 0 18px 40px rgba(22, 41, 76, 0.08);
}
.hero-media {
margin: -6px -6px 18px;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: #08111f;
}
.hero-media img {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.signal-list,
.list,
.sequence {
margin: 0;
padding-left: 20px;
}
.signal-list li,
.list li,
.sequence li {
margin-bottom: 10px;
}
.metric-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.metric {
min-height: 88px;
padding: 14px;
border-radius: 8px;
background: var(--accent-soft);
}
.metric-value {
display: block;
font-size: 26px;
font-weight: 800;
color: var(--accent-strong);
}
.metric-label {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 14px;
}
.band {
padding: 56px 0;
}
.band.alt {
background: var(--surface-alt);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.section-head {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
gap: 24px;
align-items: end;
margin-bottom: 24px;
}
.feature-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.feature,
.buyer-card,
.pricing-card,
.launch-note {
padding: 20px;
}
.buyer-grid,
.pricing-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.pricing-price {
font-size: 28px;
font-weight: 800;
color: var(--accent-strong);
margin-bottom: 10px;
}
.launch-grid {
grid-template-columns: minmax(0, 1.3fr) minmax(280px, 360px);
align-items: start;
}
.form-grid {
grid-template-columns: minmax(0, 0.8fr) minmax(360px, 1.2fr);
align-items: start;
}
.pilot-form {
padding: 22px;
}
.field-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
label {
display: flex;
flex-direction: column;
gap: 8px;
font-weight: 600;
}
input,
select,
textarea {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--text);
font: inherit;
}
textarea {
min-height: 116px;
resize: vertical;
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid rgba(14, 99, 246, 0.18);
border-color: var(--accent);
}
.form-actions {
margin-top: 18px;
}
.form-output {
margin-top: 18px;
padding: 16px;
border-radius: 8px;
background: #f8fbff;
border: 1px solid var(--line);
color: var(--muted);
white-space: pre-wrap;
}
.form-output.ready {
border-color: #b9d8ca;
background: #f3fbf6;
color: var(--success);
}
.footer {
position: relative;
z-index: 1;
border-top: 1px solid var(--line);
background: #eef3fb;
overflow: hidden;
}
.footer-row {
min-height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 18px;
padding-bottom: 18px;
}
.footer-copy {
margin-left: 10px;
color: var(--muted);
display: inline-block;
}
.footer-links {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
@media (max-width: 980px) {
h1 {
font-size: 42px;
}
h2 {
font-size: 28px;
}
.hero-grid,
.section-head,
.launch-grid,
.form-grid,
.feature-grid,
.buyer-grid,
.pricing-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.field-grid,
.metric-row {
grid-template-columns: 1fr;
}
.topbar-row {
min-height: auto;
align-items: flex-start;
justify-content: flex-start;
flex-direction: column;
padding: 14px 0;
}
.topbar-actions {
width: 100%;
}
.footer-row {
align-items: flex-start;
justify-content: flex-start;
flex-direction: column;
padding: 18px 0;
}
.lede {
font-size: 18px;
}
.band {
padding: 44px 0;
}
}

62
product/site/verify.mjs Normal file
View File

@@ -0,0 +1,62 @@
import { spawn } from "child_process";
const commands = [
{
label: "smoke",
cmd: "node",
args: ["product/site/smoke-test.cjs"],
env: {},
},
{
label: "e2e-desktop",
cmd: "node",
args: ["product/site/e2e-test.mjs"],
env: {
SQUAREMCP_E2E_PROFILE: "desktop",
SQUAREMCP_E2E_SCREENSHOT: "/tmp/squaremcp-e2e-desktop.png",
},
},
{
label: "e2e-mobile",
cmd: "node",
args: ["product/site/e2e-test.mjs"],
env: {
SQUAREMCP_E2E_PROFILE: "mobile",
SQUAREMCP_E2E_SCREENSHOT: "/tmp/squaremcp-e2e-mobile.png",
},
},
];
function runCommand({ label, cmd, args, env }) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
stdio: "inherit",
env: { ...process.env, ...env },
});
child.on("exit", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${label} failed with exit code ${code}`));
});
child.on("error", reject);
});
}
async function main() {
for (const command of commands) {
console.log(`\n==> ${command.label}`);
await runCommand(command);
}
console.log("\nsquaremcp product site verification: PASS");
console.log("desktop_screenshot: /tmp/squaremcp-e2e-desktop.png");
console.log("mobile_screenshot: /tmp/squaremcp-e2e-mobile.png");
}
main().catch((error) => {
console.error(`squaremcp product site verification: FAIL: ${error.message}`);
process.exit(1);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 KiB