Add multi-account OAuth, Obsidian integration, product assets, and test tooling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
23
.env.example
@@ -3,6 +3,12 @@
|
||||
YAHOO_EMAIL=you@yahoo.com
|
||||
YAHOO_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
|
||||
# ── Gmail ────────────────────────────────────────────────────────────────────
|
||||
# Generate an App Password at: https://myaccount.google.com/apppasswords
|
||||
# Requires 2-Step Verification to be enabled on your Google account
|
||||
GMAIL_EMAIL=you@gmail.com
|
||||
GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
|
||||
# ── Optional: Custom IMAP/SMTP Server ────────────────────────────────────────
|
||||
# Uncomment and configure these if you want to use a second email account
|
||||
# CUSTOM_EMAIL=you@yourdomain.com
|
||||
@@ -12,5 +18,22 @@ YAHOO_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
# CUSTOM_SMTP_HOST=mail.yourdomain.com
|
||||
# CUSTOM_SMTP_PORT=587
|
||||
|
||||
# ── MySQL ────────────────────────────────────────────────────────────────────
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=root
|
||||
MYSQL_PASSWORD=your-mysql-password
|
||||
|
||||
# ── Server ───────────────────────────────────────────────────────────────────
|
||||
PORT=3456
|
||||
MCP_API_KEY=your-secret-api-key
|
||||
|
||||
# ── Obsidian Vault ───────────────────────────────────────────────────────────
|
||||
# Path to the vault directory inside the container (mount in docker-compose.yml)
|
||||
OBSIDIAN_VAULT_PATH=/vaults
|
||||
# Syncthing GUI URL (accessible from within the container via host.docker.internal)
|
||||
SYNCTHING_URL=http://host.docker.internal:8384
|
||||
# API key from ~/.config/syncthing/config.xml → <apikey>
|
||||
SYNCTHING_API_KEY=your-syncthing-api-key
|
||||
# Folder ID as set in Syncthing config
|
||||
SYNCTHING_FOLDER_ID=obsidian-vault
|
||||
|
||||
10
.gitignore
vendored
@@ -3,3 +3,13 @@ dist/
|
||||
.env
|
||||
README.private.md
|
||||
.idea/
|
||||
/godaddy.md
|
||||
/promptObsydian-sync.md
|
||||
/OAUTH_MYSQL_MIGRATION_REPORT.md
|
||||
/hermes-k8s.yaml
|
||||
/promptObsydian-sync.md
|
||||
/setup.sh
|
||||
/product/site/squaremcp.com.txt
|
||||
hermes.log
|
||||
.claude/
|
||||
.codex
|
||||
|
||||
@@ -4,10 +4,22 @@ services:
|
||||
container_name: hermes-mcp
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- MYSQL_HOST=mysql
|
||||
volumes:
|
||||
- /home/garfield/obsidian/vaults:/vaults
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "3456:3456"
|
||||
- "${PORT:-3456}:${PORT:-3456}"
|
||||
networks:
|
||||
- hermes-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.hermes.rule=Host(`hermes.fetcherpay.com`)"
|
||||
- "traefik.http.routers.hermes.entrypoints=websecure"
|
||||
- "traefik.http.routers.hermes.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.hermes.loadbalancer.server.port=3456"
|
||||
|
||||
networks:
|
||||
hermes-net:
|
||||
|
||||
86
jacob-k8s-ingress.yaml
Normal file
@@ -0,0 +1,86 @@
|
||||
# K8s Ingress for jacob.fetcherpay.com
|
||||
# Option A: Route through existing nginx-ingress-controller (hostNetwork :443)
|
||||
# Applies to namespace: fetcherpay (same as hermes)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - DeerFlow nginx must be reachable from the host network on port 2026
|
||||
# (docker-compose already binds host:2026 → container:2026)
|
||||
# - cert-manager with ClusterIssuer "letsencrypt-prod" (same as hermes)
|
||||
# - microk8s kubectl apply -f jacob-k8s-ingress.yaml
|
||||
#
|
||||
# After apply, cert-manager will issue a new LE cert for jacob.fetcherpay.com
|
||||
# and nginx-ingress will serve it instead of the fake cert.
|
||||
|
||||
---
|
||||
# External Service pointing to DeerFlow nginx on the host
|
||||
# Since DeerFlow runs in Docker (not K8s), we use a headless Service + Endpoints
|
||||
# to target localhost:2026. This works because nginx-ingress runs with hostNetwork.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: jacob-deerflow
|
||||
namespace: fetcherpay
|
||||
spec:
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 2026
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Endpoints
|
||||
metadata:
|
||||
name: jacob-deerflow
|
||||
namespace: fetcherpay
|
||||
subsets:
|
||||
- addresses:
|
||||
- ip: 104.190.60.129
|
||||
ports:
|
||||
- port: 2026
|
||||
|
||||
---
|
||||
# Basic auth secret for nginx-ingress
|
||||
# Generate with: htpasswd -nbB boaz strength | base64
|
||||
# Then paste the base64 string under data.auth
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: jacob-basic-auth
|
||||
namespace: fetcherpay
|
||||
type: Opaque
|
||||
data:
|
||||
# boaz:strength (bcrypt $2y$ for nginx compat)
|
||||
auth: "Ym9hejokMnkkMDUkMU01MUkyMXhPWWU5aU14QUFmaGhHTzZza1NrUE0uVm9LemF4cFBLbzBva1A2TkRrOUI5ZGk="
|
||||
|
||||
---
|
||||
# Ingress resource
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: jacob-ingress
|
||||
namespace: fetcherpay
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/auth-type: basic
|
||||
nginx.ingress.kubernetes.io/auth-secret: jacob-basic-auth
|
||||
nginx.ingress.kubernetes.io/auth-realm: "Jacob - Authentication Required"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-buffering: "off"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- jacob.fetcherpay.com
|
||||
secretName: jacob-fetcherpay-tls
|
||||
rules:
|
||||
- host: jacob.fetcherpay.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: jacob-deerflow
|
||||
port:
|
||||
number: 80
|
||||
193
package-lock.json
generated
@@ -14,12 +14,16 @@
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.18.0",
|
||||
"imapflow": "^1.0.0",
|
||||
"mysql2": "^3.14.0",
|
||||
"nodemailer": "^6.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"playwright": "^1.59.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
@@ -1017,6 +1021,15 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -1170,6 +1183,15 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -1493,6 +1515,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -1692,6 +1723,12 @@
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -1755,6 +1792,27 @@
|
||||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru.min": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=1.30.0",
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1830,6 +1888,56 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.22.1",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.1.tgz",
|
||||
"integrity": "sha512-48+9UXehKyxxiP2pqCxUq+MSFvX+v41jwsSpFDQO/jAoFuAELutBGJUhWJnDbe82/OBlIhSBMC82WeonmznT/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"long": "^5.3.2",
|
||||
"lru.min": "^1.1.4",
|
||||
"named-placeholders": "^1.1.6",
|
||||
"sql-escaper": "^1.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2/node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru.min": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -1960,6 +2068,19 @@
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pixelmatch": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz",
|
||||
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"pngjs": "^7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pixelmatch": "bin/pixelmatch"
|
||||
}
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
@@ -1969,6 +2090,63 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
@@ -2357,6 +2535,21 @@
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=2.0.0",
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
|
||||
12
package.json
@@ -7,7 +7,13 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"product:site": "node product/site/server.mjs",
|
||||
"test:product-site": "node product/site/smoke-test.cjs",
|
||||
"test:product-site:e2e": "node product/site/e2e-test.mjs",
|
||||
"test:product-site:verify": "node product/site/verify.mjs",
|
||||
"test:product-site:cleanup": "node product/site/cleanup-test-submissions.mjs",
|
||||
"deploy:product-site:verify": "bash product/site/deploy-and-verify.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
@@ -16,12 +22,16 @@
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.18.0",
|
||||
"imapflow": "^1.0.0",
|
||||
"mysql2": "^3.14.0",
|
||||
"nodemailer": "^6.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"playwright": "^1.59.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
|
||||
106
product/README.md
Normal 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`
|
||||
71
product/animaGen/README_local_render.md
Normal 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`
|
||||
481
product/animaGen/render_squaremcp_video.py
Normal 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()
|
||||
4
product/animaGen/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
moviepy==1.0.3
|
||||
Pillow>=10,<12
|
||||
numpy>=1.26,<3
|
||||
imageio-ffmpeg>=0.4.9
|
||||
33
product/animaGen/squaremcp_launch_captions (1).srt
Normal 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.
|
||||
33
product/animaGen/squaremcp_launch_captions.srt
Normal 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.
|
||||
58
product/animaGen/squaremcp_shotlist (1).json
Normal 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."
|
||||
}
|
||||
]
|
||||
58
product/animaGen/squaremcp_shotlist (2).json
Normal 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."
|
||||
}
|
||||
]
|
||||
58
product/animaGen/squaremcp_shotlist.json
Normal 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."
|
||||
}
|
||||
]
|
||||
18
product/animaGen/squaremcp_visual_prompts (1).md
Normal 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
|
||||
18
product/animaGen/squaremcp_visual_prompts.md
Normal 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
@@ -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}")
|
||||
50
product/incubation/hermes-mcp-go-to-market.md
Normal 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
|
||||
|
||||
200
product/incubation/hermes-mcp-productization.md
Normal 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
@@ -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
|
||||
64
product/site/VERIFICATION.md
Normal 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
|
||||
```
|
||||
BIN
product/site/baselines/desktop.png
Normal file
|
After Width: | Height: | Size: 662 KiB |
BIN
product/site/baselines/mobile.png
Normal file
|
After Width: | Height: | Size: 576 KiB |
47
product/site/cleanup-test-submissions.mjs
Normal 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}`);
|
||||
53
product/site/compare-screenshot.mjs
Normal 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}`
|
||||
);
|
||||
35
product/site/deploy-and-verify.sh
Executable 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
@@ -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
@@ -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>
|
||||
19
product/site/nginx-site.conf
Normal 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
@@ -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 — 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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
13
product/site/squaremcp-certificate.yaml
Normal 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
|
||||
97
product/site/squaremcp-k8s-ingress.yaml
Normal 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
|
||||
29
product/site/squaremcp-mail-ingress.yaml
Normal 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
|
||||
BIN
product/site/squaremcp_launch.gif
Normal file
|
After Width: | Height: | Size: 584 KiB |
BIN
product/site/squaremcp_launch_poster.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
520
product/site/styles.css
Normal 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
@@ -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);
|
||||
});
|
||||
BIN
product/squaremcp_launch.gif
Normal file
|
After Width: | Height: | Size: 584 KiB |
BIN
product/squaremcp_launch_poster.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
product/squaremcp_storyboard.png
Normal file
|
After Width: | Height: | Size: 922 KiB |
78
promptObsydian-sync.md
Normal file
@@ -0,0 +1,78 @@
|
||||
|
||||
#### 2. Create Obsidian Client Module
|
||||
Create `src/clients/obsidian.ts`:
|
||||
- HTTP client for obsidian-sync.fetcherpay.com
|
||||
- Methods: searchNotes, getNote, updateNote, appendToNote, getSyncStatus
|
||||
- Error handling with proper MCP error codes
|
||||
- Retry logic for network failures
|
||||
|
||||
#### 3. Add MCP Tools (4 tools total)
|
||||
|
||||
**Tool 1: obsidian_search_notes**
|
||||
- Description: "Search across Obsidian vault by content, tags, or title. Use when user references 'my notes', 'in obsidian', or needs personal knowledge retrieval."
|
||||
- Parameters: query (string), tags (string[] optional), limit (number, default 10), path_filter (string optional)
|
||||
- Returns: Array of {path, title, excerpt, tags, modified_date}
|
||||
|
||||
**Tool 2: obsidian_read_note**
|
||||
- Description: "Retrieve full content of a specific note by path or title."
|
||||
- Parameters: path (string), format (enum: "markdown", "json", default "markdown")
|
||||
- Returns: {path, title, content, tags, links, backlinks, modified_date}
|
||||
|
||||
**Tool 3: obsidian_append_to_note**
|
||||
- Description: "Append content to a daily note or specific note. Use for logging, journaling, or capturing insights from email or other sources."
|
||||
- Parameters: path (string), content (string), create_if_missing (boolean, default true), header (string optional)
|
||||
- Returns: {success, path, bytes_written}
|
||||
|
||||
**Tool 4: obsidian_sync_status**
|
||||
- Description: "Check last sync time and status of obsidian-sync.fetcherpay.com service."
|
||||
- Parameters: none
|
||||
- Returns: {status, last_sync, vault_size, pending_changes}
|
||||
|
||||
#### 4. Update Server Registration
|
||||
Modify `src/index.ts` or main server file:
|
||||
- Import Obsidian client
|
||||
- Register all 4 new tools
|
||||
- Ensure tool names follow pattern: `obsidian_*` for namespacing
|
||||
- Add to server capabilities
|
||||
|
||||
#### 5. Add Resources (Optional but Recommended)
|
||||
Add `obsidian://` URI scheme resources:
|
||||
- `obsidian://note/{path}` - Access specific notes
|
||||
- `obsidian://search/{query}` - Search results as resource
|
||||
|
||||
#### 6. Error Handling Standards
|
||||
Follow existing hermes-mcp error patterns:
|
||||
- Use MCP error codes: -32602 (invalid params), -32603 (internal error), -32000 (server error)
|
||||
- Log errors with context but don't expose internals to client
|
||||
- Graceful degradation if obsidian-sync service is unavailable
|
||||
|
||||
#### 7. Testing Checklist
|
||||
Create or update tests:
|
||||
- [ ] Obsidian client unit tests (mock HTTP responses)
|
||||
- [ ] Tool integration tests
|
||||
- [ ] Error handling tests (timeout, 404, 500 responses)
|
||||
- [ ] End-to-end test with actual obsidian-sync service
|
||||
|
||||
### Architecture Guidelines
|
||||
- Keep consistent with existing email tool patterns in hermes-mcp
|
||||
- Use same HTTP client library as existing code (axios/fetch/node-libcurl)
|
||||
- Maintain same logging and monitoring approach
|
||||
- Follow existing TypeScript interfaces and types
|
||||
- Tool descriptions must be explicit about WHEN to use (for LLM routing)
|
||||
|
||||
### Deliverables
|
||||
1. Updated source files with new Obsidian functionality
|
||||
2. Updated `.env.example` with new variables
|
||||
3. Updated README.md with Obsidian tool documentation
|
||||
4. Test file covering new tools
|
||||
5. Verification that existing email tools still work
|
||||
|
||||
### Verification Command
|
||||
After implementation, I should be able to run:
|
||||
```bash
|
||||
npm run build
|
||||
npm run test
|
||||
# Then test with MCP inspector or Claude Code:
|
||||
# "Search my notes for 'funding strategy'"
|
||||
# "Read my daily note from today"
|
||||
# "Append 'Meeting with investor scheduled' to my daily note"
|
||||
36
scripts/archive-kimi-session.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# archive-kimi-session.sh
|
||||
# Quick wrapper to archive the most recent Kimi Code session to Obsidian.
|
||||
#
|
||||
# Usage:
|
||||
# ./archive-kimi-session.sh [topic]
|
||||
#
|
||||
# Examples:
|
||||
# ./archive-kimi-session.sh hermes-mcp-oauth-fix
|
||||
# ./archive-kimi-session.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PYTHON_SCRIPT="$SCRIPT_DIR/kimi-session-to-obsidian.py"
|
||||
SESSIONS_DIR="$HOME/.kimi/sessions"
|
||||
|
||||
# Find the most recently modified session directory
|
||||
if [ ! -d "$SESSIONS_DIR" ]; then
|
||||
echo "Error: Kimi sessions directory not found at $SESSIONS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LATEST_SESSION=$(ls -t "$SESSIONS_DIR" | head -n 1)
|
||||
|
||||
if [ -z "$LATEST_SESSION" ]; then
|
||||
echo "Error: No Kimi sessions found in $SESSIONS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOPIC="${1:-$LATEST_SESSION}"
|
||||
|
||||
echo "Archiving session: $LATEST_SESSION"
|
||||
echo "Topic: $TOPIC"
|
||||
|
||||
python3 "$PYTHON_SCRIPT" "$LATEST_SESSION" "$TOPIC"
|
||||
250
scripts/kimi-session-to-obsidian.py
Executable file
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
kimi-session-to-obsidian.py
|
||||
|
||||
Parse a Kimi Code session JSONL and convert it to a markdown note
|
||||
in the Obsidian vault under Kimi Conversations/.
|
||||
|
||||
Usage:
|
||||
python3 kimi-session-to-obsidian.py <session-id> [topic]
|
||||
|
||||
Example:
|
||||
python3 kimi-session-to-obsidian.py 4b234c03673220f26266132c420581d3 hermes-mcp-oauth-fix
|
||||
|
||||
The script reads from ~/.kimi/sessions/<session-id>/*context.jsonl
|
||||
and writes to ~/obsidian/vaults/Kimi Conversations/YYYY-MM-DD-<topic>.md
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
KIMI_SESSIONS_DIR = Path.home() / ".kimi" / "sessions"
|
||||
VAULT_DIR = Path.home() / "obsidian" / "vaults"
|
||||
OUTPUT_DIR = VAULT_DIR / "Kimi Conversations"
|
||||
|
||||
|
||||
def extract_text_content(content) -> str:
|
||||
"""Extract human-readable text from Kimi Code's structured content format."""
|
||||
if isinstance(content, list):
|
||||
texts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
texts.append(str(item.get("text", "")))
|
||||
elif isinstance(item, dict) and item.get("type") == "think":
|
||||
# Skip think blocks
|
||||
continue
|
||||
elif isinstance(item, str):
|
||||
texts.append(item)
|
||||
return " ".join(texts)
|
||||
return str(content) if content else ""
|
||||
|
||||
|
||||
def parse_context_jsonl(path: Path) -> list[dict]:
|
||||
messages = []
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
# Normalize content to plain text immediately
|
||||
if "content" in obj:
|
||||
obj["content_plain"] = extract_text_content(obj["content"])
|
||||
messages.append(obj)
|
||||
return messages
|
||||
|
||||
|
||||
def summarize_session(messages: list[dict]) -> dict:
|
||||
"""Extract summary metadata from a Kimi Code session."""
|
||||
user_msgs = []
|
||||
assistant_msgs = []
|
||||
tool_calls = []
|
||||
files_modified = set()
|
||||
project_hint = ""
|
||||
|
||||
for m in messages:
|
||||
role = m.get("role", "")
|
||||
content = m.get("content_plain", "")
|
||||
|
||||
if role == "user" and content:
|
||||
user_msgs.append(content)
|
||||
# Try to detect project from first user message
|
||||
if not project_hint and len(content) < 200:
|
||||
words = content.split()
|
||||
for w in words:
|
||||
w = w.strip(".,;:!?\"/")
|
||||
if "/" in w and not w.startswith("{"):
|
||||
project_hint = w.split("/")[-1] if "/" in w else w
|
||||
break
|
||||
elif "." in w and not w.startswith("{") and not w.startswith("http"):
|
||||
project_hint = w.split(".")[0]
|
||||
break
|
||||
|
||||
elif role == "assistant":
|
||||
assistant_msgs.append(content)
|
||||
for tc in m.get("tool_calls", []):
|
||||
fn = tc.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
args = fn.get("arguments", "")
|
||||
tool_calls.append({"name": name, "arguments": args})
|
||||
# Detect filesystem writes
|
||||
if name in ("WriteFile", "StrReplaceFile") and isinstance(args, str):
|
||||
try:
|
||||
a = json.loads(args)
|
||||
p = a.get("path", "")
|
||||
if p:
|
||||
files_modified.add(p)
|
||||
except Exception:
|
||||
pass
|
||||
elif name == "Shell":
|
||||
try:
|
||||
a = json.loads(args)
|
||||
cmd = a.get("command", "")
|
||||
# git commits, docker builds, etc.
|
||||
if cmd:
|
||||
tool_calls[-1]["command_preview"] = cmd[:200]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"user_message_count": len(user_msgs),
|
||||
"assistant_message_count": len(assistant_msgs),
|
||||
"tool_call_count": len(tool_calls),
|
||||
"first_user_message": user_msgs[0] if user_msgs else "",
|
||||
"last_assistant_message": assistant_msgs[-1] if assistant_msgs else "",
|
||||
"files_modified": sorted(files_modified),
|
||||
"tool_names_used": sorted({t["name"] for t in tool_calls}),
|
||||
"project_hint": project_hint,
|
||||
}
|
||||
|
||||
|
||||
def build_markdown(session_id: str, topic: str, summary: dict, messages: list[dict]) -> str:
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Build files section
|
||||
files_md = "\n".join(f"- `{f}`" for f in summary["files_modified"]) if summary["files_modified"] else "- *(none detected)*"
|
||||
|
||||
# Build tools section
|
||||
tools_md = "\n".join(f"- `{t}`" for t in summary["tool_names_used"]) if summary["tool_names_used"] else "- *(none detected)*"
|
||||
|
||||
# Build a lightweight transcript of key exchanges
|
||||
transcript = []
|
||||
for m in messages:
|
||||
role = m.get("role", "")
|
||||
content = m.get("content_plain", "")
|
||||
if role == "user" and content:
|
||||
transcript.append(f"> **User:** {content[:300]}{'...' if len(content) > 300 else ''}\n")
|
||||
elif role == "assistant" and content and not content.startswith("[Tool:"):
|
||||
transcript.append(f"> **Kimi:** {content[:300]}{'...' if len(content) > 300 else ''}\n")
|
||||
if len(transcript) >= 20: # Cap to keep file size reasonable
|
||||
transcript.append("> *(transcript truncated — full session in ~/.kimi/sessions/)*\n")
|
||||
break
|
||||
|
||||
transcript_md = "\n".join(transcript) if transcript else "- *(no transcript extracted)*"
|
||||
|
||||
md = f"""---
|
||||
title: {topic.replace("-", " ").title()}
|
||||
date: {date_str}
|
||||
agent: Kimi Code
|
||||
session_id: {session_id}
|
||||
tags:
|
||||
- kimi-code
|
||||
- session-archive
|
||||
project: {summary["project_hint"] or "unknown"}
|
||||
---
|
||||
|
||||
# {topic.replace("-", " ").title()}
|
||||
|
||||
**Date:** {date_str}
|
||||
**Agent:** Kimi Code
|
||||
**Session ID:** `{session_id}`
|
||||
|
||||
## Session Goal
|
||||
|
||||
{summary["first_user_message"] or "*(no goal extracted)*"}
|
||||
|
||||
## Summary
|
||||
|
||||
- **User messages:** {summary["user_message_count"]}
|
||||
- **Assistant messages:** {summary["assistant_message_count"]}
|
||||
- **Tool calls:** {summary["tool_call_count"]}
|
||||
|
||||
## Files Modified
|
||||
|
||||
{files_md}
|
||||
|
||||
## Tools Used
|
||||
|
||||
{tools_md}
|
||||
|
||||
## Key Transcript
|
||||
|
||||
{transcript_md}
|
||||
|
||||
## Final Outcome
|
||||
|
||||
{summary["last_assistant_message"] or "*(no final message)*"}
|
||||
|
||||
---
|
||||
|
||||
*Auto-archived from Kimi Code session `{session_id}`*
|
||||
"""
|
||||
return md
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <session-id> [topic]")
|
||||
print(f"Example: {sys.argv[0]} 4b234c03673220f26266132c420581d3 hermes-mcp-fix")
|
||||
sys.exit(1)
|
||||
|
||||
session_id = sys.argv[1]
|
||||
topic = sys.argv[2] if len(sys.argv) > 2 else "session-archive"
|
||||
|
||||
session_dir = KIMI_SESSIONS_DIR / session_id
|
||||
if not session_dir.exists():
|
||||
print(f"Error: session directory not found: {session_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
# Find context.jsonl files (there may be multiple sub-sessions)
|
||||
context_files = list(session_dir.rglob("context.jsonl"))
|
||||
if not context_files:
|
||||
print(f"Error: no context.jsonl found under {session_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
# Use the largest/most recent context.jsonl
|
||||
context_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
context_path = context_files[0]
|
||||
|
||||
messages = parse_context_jsonl(context_path)
|
||||
summary = summarize_session(messages)
|
||||
markdown = build_markdown(session_id, topic, summary, messages)
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
output_path = OUTPUT_DIR / f"{date_str}-{topic}.md"
|
||||
|
||||
# Avoid overwriting
|
||||
counter = 1
|
||||
original_output_path = output_path
|
||||
while output_path.exists():
|
||||
output_path = original_output_path.with_suffix(f"-{counter}.md")
|
||||
counter += 1
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(markdown)
|
||||
|
||||
print(f"Archived to: {output_path}")
|
||||
print(f" - User messages: {summary['user_message_count']}")
|
||||
print(f" - Tool calls: {summary['tool_call_count']}")
|
||||
print(f" - Files modified: {len(summary['files_modified'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
squaremcp_youtube_explainer.mp4
Normal file
15
src/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
RUN npx tsc
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
EXPOSE 3456
|
||||
CMD ["node", "dist/index.js"]
|
||||
272
src/clients/obsidian.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { readFile, writeFile, readdir, stat, mkdir } from 'fs/promises';
|
||||
import { join, dirname, basename, extname } from 'path';
|
||||
|
||||
const VAULT_PATH = process.env['OBSIDIAN_VAULT_PATH'] ?? '/vaults';
|
||||
const SYNCTHING_URL = process.env['SYNCTHING_URL'] ?? 'http://host.docker.internal:8384';
|
||||
const SYNCTHING_API_KEY = process.env['SYNCTHING_API_KEY'] ?? '';
|
||||
const SYNCTHING_FOLDER_ID = process.env['SYNCTHING_FOLDER_ID'] ?? 'obsidian-vault';
|
||||
|
||||
function parseFrontmatter(content: string): { meta: Record<string, unknown>; body: string } {
|
||||
if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) {
|
||||
return { meta: {}, body: content };
|
||||
}
|
||||
const end = content.indexOf('\n---', 4);
|
||||
if (end === -1) return { meta: {}, body: content };
|
||||
|
||||
const yaml = content.slice(4, end);
|
||||
const body = content.slice(end + 4).replace(/^\r?\n/, '');
|
||||
const meta: Record<string, unknown> = {};
|
||||
|
||||
for (const line of yaml.split('\n')) {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon === -1) continue;
|
||||
const key = line.slice(0, colon).trim();
|
||||
const val = line.slice(colon + 1).trim();
|
||||
if (!key) continue;
|
||||
if (key === 'tags') {
|
||||
// Support "tags: [a, b]" or "tags: a, b"
|
||||
meta['tags'] = val
|
||||
.replace(/[\[\]]/g, '')
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
meta[key] = val;
|
||||
}
|
||||
}
|
||||
return { meta, body };
|
||||
}
|
||||
|
||||
function extractTitle(filePath: string, content: string): string {
|
||||
const { meta, body } = parseFrontmatter(content);
|
||||
if (meta['title']) return String(meta['title']);
|
||||
const h1 = body.match(/^#\s+(.+)/m);
|
||||
if (h1) return h1[1].trim();
|
||||
return basename(filePath, '.md');
|
||||
}
|
||||
|
||||
async function getAllNotes(dir: string = VAULT_PATH): Promise<string[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await getAllNotes(full)));
|
||||
} else if (entry.isFile() && extname(entry.name) === '.md') {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function relPath(fullPath: string): string {
|
||||
return fullPath.startsWith(VAULT_PATH)
|
||||
? fullPath.slice(VAULT_PATH.length).replace(/^\//, '')
|
||||
: fullPath;
|
||||
}
|
||||
|
||||
export interface NoteResult {
|
||||
path: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
tags: string[];
|
||||
modified_date: string;
|
||||
}
|
||||
|
||||
export interface FullNote {
|
||||
path: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
links: string[];
|
||||
modified_date: string;
|
||||
}
|
||||
|
||||
export async function searchNotes(
|
||||
query: string,
|
||||
tags?: string[],
|
||||
limit = 10,
|
||||
pathFilter?: string
|
||||
): Promise<NoteResult[]> {
|
||||
const files = await getAllNotes();
|
||||
const results: NoteResult[] = [];
|
||||
const q = query.toLowerCase();
|
||||
|
||||
for (const file of files) {
|
||||
if (results.length >= limit) break;
|
||||
|
||||
const rel = relPath(file);
|
||||
if (pathFilter && !rel.toLowerCase().includes(pathFilter.toLowerCase())) continue;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(file, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { meta, body } = parseFrontmatter(content);
|
||||
const fileTags = (meta['tags'] as string[]) ?? [];
|
||||
const title = extractTitle(file, content);
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
const hasAllTags = tags.every((t) => fileTags.includes(t));
|
||||
if (!hasAllTags) continue;
|
||||
}
|
||||
|
||||
const matchesQuery =
|
||||
!q ||
|
||||
title.toLowerCase().includes(q) ||
|
||||
body.toLowerCase().includes(q) ||
|
||||
rel.toLowerCase().includes(q);
|
||||
|
||||
if (!matchesQuery) continue;
|
||||
|
||||
let excerpt = body.slice(0, 200).replace(/\n+/g, ' ').trim();
|
||||
if (q) {
|
||||
const idx = body.toLowerCase().indexOf(q);
|
||||
if (idx > 0) {
|
||||
excerpt = body.slice(Math.max(0, idx - 50), idx + 150).replace(/\n+/g, ' ').trim();
|
||||
}
|
||||
}
|
||||
if (excerpt.length >= 150) excerpt += '...';
|
||||
|
||||
const s = await stat(file);
|
||||
results.push({
|
||||
path: rel,
|
||||
title,
|
||||
excerpt,
|
||||
tags: fileTags,
|
||||
modified_date: s.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getNote(notePath: string): Promise<FullNote> {
|
||||
let fullPath = notePath.startsWith('/') ? notePath : join(VAULT_PATH, notePath);
|
||||
|
||||
// If path doesn't end in .md, try adding it
|
||||
if (!fullPath.endsWith('.md')) fullPath += '.md';
|
||||
|
||||
try {
|
||||
await stat(fullPath);
|
||||
} catch {
|
||||
// Fall back to searching by title / filename match
|
||||
const files = await getAllNotes();
|
||||
const needle = notePath.replace(/\.md$/i, '').toLowerCase();
|
||||
const match = files.find(
|
||||
(f) =>
|
||||
basename(f, '.md').toLowerCase() === needle ||
|
||||
relPath(f).replace(/\.md$/i, '').toLowerCase() === needle
|
||||
);
|
||||
if (!match) throw new Error(`Note not found: ${notePath}`);
|
||||
fullPath = match;
|
||||
}
|
||||
|
||||
const content = await readFile(fullPath, 'utf-8');
|
||||
const { meta, body } = parseFrontmatter(content);
|
||||
const title = extractTitle(fullPath, content);
|
||||
const s = await stat(fullPath);
|
||||
|
||||
const links = [...body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)].map((m) => m[1].trim());
|
||||
|
||||
return {
|
||||
path: relPath(fullPath),
|
||||
title,
|
||||
content,
|
||||
tags: (meta['tags'] as string[]) ?? [],
|
||||
links: [...new Set(links)],
|
||||
modified_date: s.mtime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function appendToNote(
|
||||
notePath: string,
|
||||
content: string,
|
||||
createIfMissing = true,
|
||||
header?: string
|
||||
): Promise<{ success: boolean; path: string; bytes_written: number }> {
|
||||
let fullPath = notePath.startsWith('/') ? notePath : join(VAULT_PATH, notePath);
|
||||
if (!fullPath.endsWith('.md')) fullPath += '.md';
|
||||
|
||||
let existing = '';
|
||||
try {
|
||||
existing = await readFile(fullPath, 'utf-8');
|
||||
} catch {
|
||||
if (!createIfMissing) throw new Error(`Note not found: ${notePath}`);
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
}
|
||||
|
||||
const separator = existing && !existing.endsWith('\n') ? '\n' : '';
|
||||
const toAppend = header
|
||||
? `${separator}\n## ${header}\n${content}\n`
|
||||
: `${separator}${content}\n`;
|
||||
|
||||
await writeFile(fullPath, existing + toAppend, 'utf-8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: relPath(fullPath),
|
||||
bytes_written: Buffer.byteLength(toAppend, 'utf-8'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateNote(
|
||||
notePath: string,
|
||||
content: string,
|
||||
): Promise<{ success: boolean; path: string; bytes_written: number }> {
|
||||
let fullPath = notePath.startsWith('/') ? notePath : join(VAULT_PATH, notePath);
|
||||
if (!fullPath.endsWith('.md')) fullPath += '.md';
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
await writeFile(fullPath, content, 'utf-8');
|
||||
return { success: true, path: relPath(fullPath), bytes_written: Buffer.byteLength(content, 'utf-8') };
|
||||
}
|
||||
|
||||
export async function getSyncStatus(): Promise<{
|
||||
status: string;
|
||||
last_sync: string | null;
|
||||
vault_size: number;
|
||||
pending_changes: number;
|
||||
}> {
|
||||
if (!SYNCTHING_API_KEY) {
|
||||
return { status: 'unconfigured – set SYNCTHING_API_KEY', last_sync: null, vault_size: 0, pending_changes: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${SYNCTHING_URL}/rest/db/status?folder=${SYNCTHING_FOLDER_ID}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'X-API-Key': SYNCTHING_API_KEY },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Syncthing API returned ${res.status}`);
|
||||
|
||||
const data = (await res.json()) as {
|
||||
state: string;
|
||||
stateChanged: string;
|
||||
localBytes: number;
|
||||
needFiles: number;
|
||||
};
|
||||
|
||||
return {
|
||||
status: data.state,
|
||||
last_sync: data.stateChanged ?? null,
|
||||
vault_size: data.localBytes ?? 0,
|
||||
pending_changes: data.needFiles ?? 0,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: `error: ${(err as Error).message}`,
|
||||
last_sync: null,
|
||||
vault_size: 0,
|
||||
pending_changes: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
81
src/db.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const host = process.env.MYSQL_HOST || '127.0.0.1';
|
||||
const port = parseInt(process.env.MYSQL_PORT || '3306', 10);
|
||||
const user = process.env.MYSQL_USER || 'root';
|
||||
const password = process.env.MYSQL_PASSWORD || '';
|
||||
|
||||
let pool: mysql.Pool | null = null;
|
||||
|
||||
export function getPool(): mysql.Pool {
|
||||
if (!pool) {
|
||||
throw new Error('Database pool not initialized. Call initDatabase() first.');
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export function isPoolReady(): boolean {
|
||||
return pool !== null;
|
||||
}
|
||||
|
||||
export async function initDatabase(): Promise<void> {
|
||||
// Create database if it doesn't exist
|
||||
const tmpConn = await mysql.createConnection({ host, port, user, password });
|
||||
await tmpConn.execute('CREATE DATABASE IF NOT EXISTS hermes_oauth');
|
||||
await tmpConn.end();
|
||||
|
||||
pool = mysql.createPool({
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
database: 'hermes_oauth',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
timezone: 'Z',
|
||||
});
|
||||
|
||||
const db = await pool.getConnection();
|
||||
try {
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
client_id VARCHAR(255) PRIMARY KEY,
|
||||
client_secret VARCHAR(255) NOT NULL,
|
||||
client_name VARCHAR(255),
|
||||
redirect_urls JSON,
|
||||
grant_types JSON,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used TIMESTAMP NULL,
|
||||
is_static BOOLEAN DEFAULT FALSE,
|
||||
INDEX idx_last_used (last_used)
|
||||
)
|
||||
`);
|
||||
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_auth_codes (
|
||||
code VARCHAR(255) PRIMARY KEY,
|
||||
client_id VARCHAR(255),
|
||||
redirect_uri TEXT,
|
||||
expires_at TIMESTAMP,
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
INDEX idx_expires (expires_at)
|
||||
)
|
||||
`);
|
||||
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
token VARCHAR(255) PRIMARY KEY,
|
||||
client_id VARCHAR(255),
|
||||
token_type ENUM('access', 'refresh') DEFAULT 'access',
|
||||
expires_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_expires (expires_at)
|
||||
)
|
||||
`);
|
||||
} finally {
|
||||
db.release();
|
||||
}
|
||||
|
||||
console.log('[db] MySQL connected and schema initialized');
|
||||
}
|
||||
174
src/imap.ts
@@ -1,6 +1,6 @@
|
||||
import { ImapFlow } from 'imapflow';
|
||||
|
||||
export type Account = 'yahoo' | 'fetcherpay' | 'garfield' | 'sales' | 'leads' | 'founder';
|
||||
export type Account = 'yahoo' | 'fetcherpay' | 'garfield' | 'sales' | 'leads' | 'founder' | 'gmail';
|
||||
|
||||
const FETCHERPAY_IMAP_HOST = process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com';
|
||||
const FETCHERPAY_IMAP_PORT = parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993');
|
||||
@@ -42,6 +42,16 @@ function getConfig(account: Account = 'yahoo') {
|
||||
process.env['FOUNDER_EMAIL'] as string,
|
||||
process.env['FOUNDER_PASSWORD'] as string,
|
||||
);
|
||||
case 'gmail':
|
||||
return {
|
||||
host: 'imap.gmail.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env['GMAIL_EMAIL'] as string,
|
||||
pass: process.env['GMAIL_APP_PASSWORD'] as string,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return {
|
||||
host: 'imap.mail.yahoo.com',
|
||||
@@ -73,6 +83,7 @@ export interface MessageSummary {
|
||||
date: string;
|
||||
seen: boolean;
|
||||
size: number;
|
||||
folder: string;
|
||||
}
|
||||
|
||||
export interface FullMessage {
|
||||
@@ -86,48 +97,142 @@ export interface FullMessage {
|
||||
seen: boolean;
|
||||
}
|
||||
|
||||
export async function searchMessages(query: string, maxResults = 20, account: Account = 'yahoo'): Promise<MessageSummary[]> {
|
||||
return withClient(account, async (client) => {
|
||||
await client.mailboxOpen('INBOX');
|
||||
function parseSearchCriteria(query: string): object {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return { all: true };
|
||||
|
||||
const criteria = query
|
||||
? { or: [{ subject: query }, { from: query }] }
|
||||
: { all: true };
|
||||
// Parse quoted and unquoted tokens like from:x, subject:y, to:z
|
||||
const tokens: { key: string; value: string }[] = [];
|
||||
const regex = /(\w+):("([^"]*)"|([^\s]+))/g;
|
||||
let m: RegExpExecArray | null;
|
||||
let lastIndex = 0;
|
||||
while ((m = regex.exec(trimmed)) !== null) {
|
||||
tokens.push({ key: m[1].toLowerCase(), value: m[3] ?? m[4] });
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const uids = await client.search(criteria as any, { uid: true });
|
||||
const uidList: number[] = Array.isArray(uids) ? uids : [];
|
||||
const recentUids = uidList.slice(-maxResults).reverse();
|
||||
const remaining = trimmed.slice(lastIndex).trim();
|
||||
if (remaining) {
|
||||
tokens.push({ key: 'keyword', value: remaining });
|
||||
}
|
||||
|
||||
if (recentUids.length === 0) return [];
|
||||
if (tokens.length === 0) {
|
||||
return { or: [{ subject: trimmed }, { from: trimmed }] };
|
||||
}
|
||||
|
||||
const messages: MessageSummary[] = [];
|
||||
for await (const msg of client.fetch(recentUids, {
|
||||
envelope: true,
|
||||
flags: true,
|
||||
size: true,
|
||||
}, { uid: true })) {
|
||||
const env = msg.envelope;
|
||||
messages.push({
|
||||
uid: msg.uid,
|
||||
messageId: env?.messageId ?? '',
|
||||
subject: env?.subject ?? '(no subject)',
|
||||
from: env?.from?.[0]
|
||||
? `${env.from[0].name ?? ''} <${env.from[0].address ?? ''}>`.trim()
|
||||
: '',
|
||||
date: env?.date?.toISOString() ?? '',
|
||||
seen: msg.flags?.has('\\Seen') ?? false,
|
||||
size: msg.size ?? 0,
|
||||
});
|
||||
const parts: object[] = [];
|
||||
for (const t of tokens) {
|
||||
switch (t.key) {
|
||||
case 'from':
|
||||
parts.push({ from: t.value });
|
||||
break;
|
||||
case 'subject':
|
||||
parts.push({ subject: t.value });
|
||||
break;
|
||||
case 'to':
|
||||
parts.push({ to: t.value });
|
||||
break;
|
||||
case 'after':
|
||||
case 'since': {
|
||||
const d = new Date(t.value);
|
||||
if (!isNaN(d.getTime())) parts.push({ since: d });
|
||||
break;
|
||||
}
|
||||
case 'before': {
|
||||
const d = new Date(t.value);
|
||||
if (!isNaN(d.getTime())) parts.push({ before: d });
|
||||
break;
|
||||
}
|
||||
case 'keyword':
|
||||
default:
|
||||
parts.push({ or: [{ subject: t.value }, { from: t.value }] });
|
||||
break;
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (parts.length === 1) return parts[0];
|
||||
return { and: parts };
|
||||
}
|
||||
|
||||
async function searchInFolder(
|
||||
client: ImapFlow,
|
||||
folder: string,
|
||||
query: string,
|
||||
maxResults: number,
|
||||
account: Account
|
||||
): Promise<MessageSummary[]> {
|
||||
await client.mailboxOpen(folder);
|
||||
|
||||
const criteria = account === 'gmail'
|
||||
? { gmailraw: query }
|
||||
: parseSearchCriteria(query);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const uids = await client.search(criteria as any, { uid: true });
|
||||
const uidList: number[] = Array.isArray(uids) ? uids : [];
|
||||
const recentUids = uidList.slice(-maxResults).reverse();
|
||||
|
||||
if (recentUids.length === 0) return [];
|
||||
|
||||
const messages: MessageSummary[] = [];
|
||||
for await (const msg of client.fetch(recentUids, {
|
||||
envelope: true,
|
||||
flags: true,
|
||||
size: true,
|
||||
}, { uid: true })) {
|
||||
const env = msg.envelope;
|
||||
messages.push({
|
||||
uid: msg.uid,
|
||||
messageId: env?.messageId ?? '',
|
||||
subject: env?.subject ?? '(no subject)',
|
||||
from: env?.from?.[0]
|
||||
? `${env.from[0].name ?? ''} <${env.from[0].address ?? ''}>`.trim()
|
||||
: '',
|
||||
date: env?.date?.toISOString() ?? '',
|
||||
seen: msg.flags?.has('\\Seen') ?? false,
|
||||
size: msg.size ?? 0,
|
||||
folder,
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
export async function searchMessages(
|
||||
query: string,
|
||||
maxResults = 20,
|
||||
account: Account = 'yahoo',
|
||||
folder?: string
|
||||
): Promise<MessageSummary[]> {
|
||||
return withClient(account, async (client) => {
|
||||
const foldersToSearch: string[] = [];
|
||||
|
||||
if (folder) {
|
||||
foldersToSearch.push(folder);
|
||||
} else if (account === 'gmail') {
|
||||
foldersToSearch.push('INBOX');
|
||||
} else {
|
||||
foldersToSearch.push('INBOX');
|
||||
}
|
||||
|
||||
for (const f of foldersToSearch) {
|
||||
const results = await searchInFolder(client, f, query, maxResults, account);
|
||||
if (results.length > 0) return results;
|
||||
}
|
||||
|
||||
// Fallback for Gmail: search All Mail if INBOX was empty
|
||||
if (account === 'gmail' && !folder) {
|
||||
const allMailResults = await searchInFolder(client, '[Gmail]/All Mail', query, maxResults, account);
|
||||
if (allMailResults.length > 0) return allMailResults;
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function readMessage(uid: number, account: Account = 'yahoo'): Promise<FullMessage> {
|
||||
export async function readMessage(uid: number, account: Account = 'yahoo', folder = 'INBOX'): Promise<FullMessage> {
|
||||
return withClient(account, async (client) => {
|
||||
console.log(`[imap] readMessage uid=${uid} account=${account}`);
|
||||
await client.mailboxOpen('INBOX');
|
||||
console.log(`[imap] readMessage uid=${uid} account=${account} folder=${folder}`);
|
||||
await client.mailboxOpen(folder);
|
||||
console.log(`[imap] mailbox opened, fetching uid=${uid}`);
|
||||
|
||||
let result: FullMessage | null = null;
|
||||
@@ -195,6 +300,7 @@ export async function getProfile(account: Account = 'yahoo'): Promise<{ email: s
|
||||
sales: process.env['SALES_EMAIL'] ?? '',
|
||||
leads: process.env['LEADS_EMAIL'] ?? '',
|
||||
founder: process.env['FOUNDER_EMAIL'] ?? '',
|
||||
gmail: process.env['GMAIL_EMAIL'] ?? '',
|
||||
};
|
||||
const email = emailMap[account] ?? '';
|
||||
return { email, name: email.split('@')[0], account };
|
||||
|
||||
590
src/index.ts
@@ -7,24 +7,189 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
isInitializeRequest,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { tools, handleToolCall } from './tools.js';
|
||||
import { getManifest, getOpenApiSpec } from './manifest.js';
|
||||
import {
|
||||
registerClient,
|
||||
getClient,
|
||||
createAuthCode,
|
||||
exchangeCodeForToken,
|
||||
validateAccessToken,
|
||||
getAuthorizeHtml,
|
||||
} from './oauth.js';
|
||||
import { initDatabase } from './db.js';
|
||||
|
||||
const app = express();
|
||||
app.use(cors({
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'mcp-session-id', 'Accept'],
|
||||
allowedHeaders: ['Content-Type', 'mcp-session-id', 'Accept', 'x-api-key', 'Authorization'],
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// ── Config ─────────────────────────────────────────────────────────────────
|
||||
const PORT = process.env.PORT ?? 3456;
|
||||
const SERVER_URL = process.env.SERVER_URL ?? `http://localhost:${PORT}`;
|
||||
const MCP_RESOURCE_URL = `${SERVER_URL}/mcp`;
|
||||
const PROTECTED_RESOURCE_METADATA_URL = `${SERVER_URL}/.well-known/oauth-protected-resource`;
|
||||
const SQUAREMCP_ALLOWED_ORIGINS = new Set([
|
||||
'https://squaremcp.com',
|
||||
'https://www.squaremcp.com',
|
||||
]);
|
||||
|
||||
type PilotRequestBody = {
|
||||
name: string;
|
||||
email: string;
|
||||
company: string;
|
||||
role: string;
|
||||
use_case: string;
|
||||
timeline: string;
|
||||
systems: string;
|
||||
requirements: string;
|
||||
submission_tag: string;
|
||||
};
|
||||
|
||||
function getEasternDateString() {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'America/New_York',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
}
|
||||
|
||||
function sanitizeField(value: unknown) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function getPilotRequestBody(body: Record<string, unknown>): PilotRequestBody {
|
||||
return {
|
||||
name: sanitizeField(body.name),
|
||||
email: sanitizeField(body.email),
|
||||
company: sanitizeField(body.company),
|
||||
role: sanitizeField(body.role),
|
||||
use_case: sanitizeField(body.use_case),
|
||||
timeline: sanitizeField(body.timeline),
|
||||
systems: sanitizeField(body.systems),
|
||||
requirements: sanitizeField(body.requirements),
|
||||
submission_tag: sanitizeField(body.submission_tag),
|
||||
};
|
||||
}
|
||||
|
||||
function validatePilotRequest(body: PilotRequestBody) {
|
||||
const requiredFields: Array<keyof PilotRequestBody> = [
|
||||
'name',
|
||||
'email',
|
||||
'company',
|
||||
'role',
|
||||
'use_case',
|
||||
'timeline',
|
||||
'systems',
|
||||
'requirements',
|
||||
];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!body[field]) {
|
||||
return `Missing required field: ${field}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
|
||||
return 'Invalid email address';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatPilotRequestMarkdown(requestId: string, body: PilotRequestBody, req: express.Request) {
|
||||
const submittedAt = new Date().toISOString();
|
||||
const source = req.get('origin') || req.get('host') || 'unknown';
|
||||
const ipAddress = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
|
||||
return [
|
||||
`### ${body.company} — ${body.name}`,
|
||||
`- Request ID: \`${requestId}\``,
|
||||
`- Submitted: ${submittedAt}`,
|
||||
`- Source: ${source}`,
|
||||
`- IP: ${ipAddress}`,
|
||||
`- Email: ${body.email}`,
|
||||
`- Role: ${body.role}`,
|
||||
`- Use case: ${body.use_case}`,
|
||||
`- Timeline: ${body.timeline}`,
|
||||
...(body.submission_tag ? [`- Tags: ${body.submission_tag}`] : []),
|
||||
'',
|
||||
'**Internal systems to connect**',
|
||||
body.systems,
|
||||
'',
|
||||
'**Security or compliance requirements**',
|
||||
body.requirements,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function appendPilotRequestToVault(requestId: string, body: PilotRequestBody, req: express.Request) {
|
||||
const content = formatPilotRequestMarkdown(requestId, body, req);
|
||||
const dailyNotePath = `Daily Notes/${getEasternDateString()}.md`;
|
||||
|
||||
await handleToolCall('obsidian_append_to_note', {
|
||||
path: 'SquareMCP/Pilot Requests.md',
|
||||
header: 'Pilot Requests',
|
||||
content,
|
||||
create_if_missing: true,
|
||||
});
|
||||
|
||||
await handleToolCall('obsidian_append_to_note', {
|
||||
path: dailyNotePath,
|
||||
header: 'SquareMCP Pilot Requests',
|
||||
content,
|
||||
create_if_missing: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Auth middleware ─────────────────────────────────────────────────────────
|
||||
const API_KEY = process.env.MCP_API_KEY;
|
||||
|
||||
function extractBearerToken(req: express.Request): string | undefined {
|
||||
const authHeader = req.headers.authorization as string | undefined;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.slice(7);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
try {
|
||||
// No API key configured = open access
|
||||
if (!API_KEY) return next();
|
||||
|
||||
// 1. Check x-api-key header or query param (backward compatibility)
|
||||
const apiKeyProvided = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined);
|
||||
if (apiKeyProvided === API_KEY) return next();
|
||||
|
||||
// 2. Check OAuth Bearer token
|
||||
const bearerToken = extractBearerToken(req);
|
||||
if (bearerToken && await validateAccessToken(bearerToken)) return next();
|
||||
|
||||
res.setHeader(
|
||||
'WWW-Authenticate',
|
||||
`Bearer realm="hermes", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`
|
||||
);
|
||||
res.status(401).json({ error: 'Unauthorized — provide x-api-key header, ?key= query param, or Bearer token' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] ${req.method} ${req.path} - ${req.headers['user-agent'] || 'no-ua'}`);
|
||||
if (req.body && Object.keys(req.body).length > 0) {
|
||||
if (req.body && Object.keys(req.body).length > 0 && !req.path.startsWith('/oauth')) {
|
||||
console.log(` Body: ${JSON.stringify(req.body).substring(0, 500)}`);
|
||||
}
|
||||
next();
|
||||
@@ -33,9 +198,10 @@ app.use((req, res, next) => {
|
||||
function createMcpServer() {
|
||||
const server = new Server(
|
||||
{ name: 'hermes', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } }
|
||||
{ capabilities: { tools: {}, resources: {} } }
|
||||
);
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
return handleToolCall(
|
||||
request.params.name,
|
||||
@@ -45,49 +211,157 @@ function createMcpServer() {
|
||||
return server;
|
||||
}
|
||||
|
||||
// ── NEW: Streamable HTTP transport (MCP 1.x standard) ──────────────────────
|
||||
// ── OAuth 2.0 + Dynamic Client Registration ─────────────────────────────────
|
||||
|
||||
// DCR: ChatGPT registers itself
|
||||
app.post('/oauth/register', async (req, res) => {
|
||||
const body = req.body || {};
|
||||
const client = await registerClient(body);
|
||||
res.status(201).json({
|
||||
client_id: client.client_id,
|
||||
client_secret: client.client_secret,
|
||||
client_name: client.client_name,
|
||||
redirect_uris: client.redirect_uris,
|
||||
grant_types: ['authorization_code'],
|
||||
token_endpoint_auth_method: 'client_secret_post',
|
||||
});
|
||||
});
|
||||
|
||||
// Authorization endpoint: GET shows consent form, POST handles approval
|
||||
app.get('/oauth/authorize', async (req, res) => {
|
||||
const clientId = req.query.client_id as string | undefined;
|
||||
const redirectUri = req.query.redirect_uri as string | undefined;
|
||||
const state = req.query.state as string | undefined;
|
||||
const scope = req.query.scope as string | undefined;
|
||||
const responseType = req.query.response_type as string | undefined;
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
res.status(400).send('Missing client_id or redirect_uri');
|
||||
return;
|
||||
}
|
||||
if (responseType && responseType !== 'code') {
|
||||
res.status(400).send('Unsupported response_type');
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getClient(clientId);
|
||||
if (!client) {
|
||||
res.status(400).send('Invalid client_id');
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(getAuthorizeHtml({ client_id: clientId, redirect_uri: redirectUri, state, scope }));
|
||||
});
|
||||
|
||||
app.post('/oauth/authorize', async (req, res) => {
|
||||
const clientId = req.body.client_id as string | undefined;
|
||||
const redirectUri = req.body.redirect_uri as string | undefined;
|
||||
const state = req.body.state as string | undefined;
|
||||
const scope = req.body.scope as string | undefined;
|
||||
const action = req.body.action as string | undefined;
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
res.status(400).send('Missing client_id or redirect_uri');
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getClient(clientId);
|
||||
if (!client) {
|
||||
res.status(400).send('Invalid client_id');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action !== 'allow') {
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set('error', 'access_denied');
|
||||
url.searchParams.set('error_description', 'User denied authorization');
|
||||
if (state) url.searchParams.set('state', state);
|
||||
res.redirect(url.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
const code = await createAuthCode(clientId, redirectUri, scope);
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set('code', code.code);
|
||||
if (state) url.searchParams.set('state', state);
|
||||
res.redirect(url.toString());
|
||||
});
|
||||
|
||||
// Token endpoint: exchange code for access token
|
||||
app.post('/oauth/token', async (req, res) => {
|
||||
const grantType = req.body.grant_type as string | undefined;
|
||||
if (grantType !== 'authorization_code') {
|
||||
res.status(400).json({ error: 'unsupported_grant_type' });
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = req.body.client_id as string | undefined;
|
||||
const clientSecret = req.body.client_secret as string | undefined;
|
||||
const code = req.body.code as string | undefined;
|
||||
const redirectUri = req.body.redirect_uri as string | undefined;
|
||||
|
||||
if (!clientId || !clientSecret || !code || !redirectUri) {
|
||||
res.status(400).json({ error: 'invalid_request' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri);
|
||||
if (!token) {
|
||||
res.status(400).json({ error: 'invalid_grant' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
access_token: token.access_token,
|
||||
token_type: token.token_type,
|
||||
expires_in: token.expires_in,
|
||||
scope: token.scope,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Streamable HTTP transport (MCP 1.x standard) ────────────────────────────
|
||||
const httpTransports = new Map<string, StreamableHTTPServerTransport>();
|
||||
|
||||
app.post('/mcp', async (req, res) => {
|
||||
async function createSession(): Promise<StreamableHTTPServerTransport> {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
console.log(`[mcp] Session initialized: ${id}`);
|
||||
httpTransports.set(id, transport);
|
||||
},
|
||||
});
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
console.log(`[mcp] Session closed: ${transport.sessionId}`);
|
||||
httpTransports.delete(transport.sessionId);
|
||||
}
|
||||
};
|
||||
const server = createMcpServer();
|
||||
await server.connect(transport);
|
||||
return transport;
|
||||
}
|
||||
|
||||
app.post('/mcp', requireAuth, async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
console.log(`[mcp] POST sessionId=${sessionId ?? 'none'}, isInit=${isInitializeRequest(req.body)}`);
|
||||
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
|
||||
if (sessionId && httpTransports.has(sessionId)) {
|
||||
// Known active session — reuse it
|
||||
console.log(`[mcp] Reusing existing session ${sessionId}`);
|
||||
transport = httpTransports.get(sessionId)!;
|
||||
} else if (isInitializeRequest(req.body)) {
|
||||
// Initialize request: create a new session.
|
||||
// Handles both first-connect (no sessionId) and re-connect after pod restart
|
||||
// (stale sessionId present but not in map — we simply ignore it and issue a fresh one).
|
||||
if (sessionId) {
|
||||
console.warn(`[mcp] Stale session ${sessionId} re-initializing — pod may have restarted`);
|
||||
}
|
||||
console.log(`[mcp] Creating new session`);
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
console.log(`[mcp] Session initialized: ${id}`);
|
||||
httpTransports.set(id, transport);
|
||||
},
|
||||
});
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
console.log(`[mcp] Session closed: ${transport.sessionId}`);
|
||||
httpTransports.delete(transport.sessionId);
|
||||
}
|
||||
};
|
||||
const server = createMcpServer();
|
||||
await server.connect(transport);
|
||||
console.log(`[mcp] Server connected to transport`);
|
||||
transport = await createSession();
|
||||
} else {
|
||||
// Unknown session + non-initialize request: session expired (e.g. pod restarted).
|
||||
// Return 404 so MCP clients know to re-initialize rather than keep retrying.
|
||||
console.warn(`[mcp] Unknown session ${sessionId ?? '(none)'} — returning 404`);
|
||||
res.status(404).json({ error: 'Session expired — please re-initialize' });
|
||||
return;
|
||||
// Stale session ID from a pod restart — transparently create a new session
|
||||
// and handle the request. Our tools are stateless so no context is lost.
|
||||
console.warn(`[mcp] Unknown session ${sessionId ?? '(none)'} — auto-recovering with new session`);
|
||||
transport = await createSession();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -99,7 +373,7 @@ app.post('/mcp', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/mcp', async (req, res) => {
|
||||
app.get('/mcp', requireAuth, async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !httpTransports.has(sessionId)) {
|
||||
res.status(400).json({ error: 'No active session' });
|
||||
@@ -108,7 +382,7 @@ app.get('/mcp', async (req, res) => {
|
||||
await httpTransports.get(sessionId)!.handleRequest(req, res);
|
||||
});
|
||||
|
||||
app.delete('/mcp', async (req, res) => {
|
||||
app.delete('/mcp', requireAuth, async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId && httpTransports.has(sessionId)) {
|
||||
httpTransports.delete(sessionId);
|
||||
@@ -116,10 +390,10 @@ app.delete('/mcp', async (req, res) => {
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
// ── LEGACY: SSE transport (kept for compatibility) ──────────────────────────
|
||||
// ── LEGACY: SSE transport ──────────────────────────────────────────────────
|
||||
const sseTransports = new Map<string, SSEServerTransport>();
|
||||
|
||||
app.get('/sse', async (req, res) => {
|
||||
app.get('/sse', requireAuth, async (req, res) => {
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
sseTransports.set(transport.sessionId, transport);
|
||||
res.on('close', () => sseTransports.delete(transport.sessionId));
|
||||
@@ -127,7 +401,7 @@ app.get('/sse', async (req, res) => {
|
||||
await server.connect(transport);
|
||||
});
|
||||
|
||||
app.post('/messages', async (req, res) => {
|
||||
app.post('/messages', requireAuth, async (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const transport = sseTransports.get(sessionId);
|
||||
if (!transport) {
|
||||
@@ -137,14 +411,244 @@ app.post('/messages', async (req, res) => {
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
// ── Health ──────────────────────────────────────────────────────────────────
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', service: 'hermes-mcp' });
|
||||
// ── Tool manifest endpoint ──────────────────────────────────────────────────
|
||||
app.get('/tools', requireAuth, (_req, res) => {
|
||||
res.json(getManifest(SERVER_URL, !!API_KEY));
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT ?? 3456;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Hermes MCP server running on port ${PORT}`);
|
||||
console.log(` Streamable HTTP: http://localhost:${PORT}/mcp`);
|
||||
console.log(` SSE (legacy): http://localhost:${PORT}/sse`);
|
||||
// ── ChatGPT MCP connector proxy ─────────────────────────────────────────────
|
||||
// ChatGPT routes MCP tool calls as: POST /hermes-mcp/link_{id}/{toolName}
|
||||
app.post('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) => {
|
||||
const { toolName } = req.params;
|
||||
const args = (req.body ?? {}) as Record<string, unknown>;
|
||||
console.log(`[chatgpt-mcp] ${toolName}`, JSON.stringify(args).substring(0, 200));
|
||||
try {
|
||||
const result = await handleToolCall(toolName, args);
|
||||
const text = result.content[0].text;
|
||||
if (text.startsWith('Error:')) {
|
||||
res.status(400).json({ error: text.slice(7).trim() });
|
||||
return;
|
||||
}
|
||||
try { res.json(JSON.parse(text)); } catch { res.json({ result: text }); }
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) => {
|
||||
const { toolName } = req.params;
|
||||
const args = req.query as Record<string, unknown>;
|
||||
console.log(`[chatgpt-mcp] GET ${toolName}`, JSON.stringify(args).substring(0, 200));
|
||||
try {
|
||||
const result = await handleToolCall(toolName, args);
|
||||
const text = result.content[0].text;
|
||||
if (text.startsWith('Error:')) {
|
||||
res.status(400).json({ error: text.slice(7).trim() });
|
||||
return;
|
||||
}
|
||||
try { res.json(JSON.parse(text)); } catch { res.json({ result: text }); }
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── ChatGPT plugin discovery ────────────────────────────────────────────────
|
||||
app.get('/.well-known/ai-plugin.json', (_req, res) => {
|
||||
res.json({
|
||||
schema_version: 'v1',
|
||||
name_for_human: 'Hermes',
|
||||
name_for_model: 'hermes',
|
||||
description_for_human: 'Access your Obsidian vault notes and email accounts.',
|
||||
description_for_model: 'Hermes provides read/write access to an Obsidian markdown vault (search, read, append, overwrite notes) and email operations across multiple accounts. Always use exact relative vault paths returned by search when reading or writing notes.',
|
||||
auth: {
|
||||
type: 'oauth',
|
||||
client_url: `${SERVER_URL}/oauth/authorize`,
|
||||
scope: 'obsidian email',
|
||||
authorization_url: `${SERVER_URL}/oauth/token`,
|
||||
authorization_content_type: 'application/x-www-form-urlencoded',
|
||||
verification_tokens: {},
|
||||
},
|
||||
api: {
|
||||
type: 'openapi',
|
||||
url: `${SERVER_URL}/openapi.json`,
|
||||
},
|
||||
contact_email: 'garfield@fetcherpay.com',
|
||||
legal_info_url: 'https://squaremcp.com/privacy',
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/openapi.json', (_req, res) => {
|
||||
res.json(getOpenApiSpec(SERVER_URL));
|
||||
});
|
||||
|
||||
// ── Obsidian REST API (ChatGPT Actions) ────────────────────────────────────
|
||||
|
||||
function parseToolResult(result: { content: Array<{ type: string; text: string }> }): unknown {
|
||||
const text = result.content[0].text;
|
||||
if (text.startsWith('Error:')) throw new Error(text.slice(7).trim());
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
}
|
||||
|
||||
app.get('/api/obsidian/search', requireAuth, async (req, res) => {
|
||||
const query = req.query.query as string | undefined;
|
||||
if (!query) { res.status(400).json({ error: 'query is required' }); return; }
|
||||
const limit = req.query.limit ? Number(req.query.limit) : 10;
|
||||
const path_filter = req.query.path_filter as string | undefined;
|
||||
const tagsRaw = req.query.tags as string | undefined;
|
||||
const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : undefined;
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_search_notes', { query, limit, path_filter, tags });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/obsidian/note', requireAuth, async (req, res) => {
|
||||
const path = req.query.path as string | undefined;
|
||||
if (!path) { res.status(400).json({ error: 'path is required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_read_note', { path });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
res.status(msg.toLowerCase().includes('not found') ? 404 : 500).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/obsidian/note/append', requireAuth, async (req, res) => {
|
||||
const { path, content, header, create_if_missing } = req.body as Record<string, unknown>;
|
||||
if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_append_to_note', { path, content, header, create_if_missing });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/obsidian/note', requireAuth, async (req, res) => {
|
||||
const { path, content } = req.body as Record<string, unknown>;
|
||||
if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; }
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_update_note', { path, content });
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/obsidian/sync', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const result = await handleToolCall('obsidian_sync_status', {});
|
||||
res.json(parseToolResult(result));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/pilot-request', async (req, res) => {
|
||||
const origin = req.get('origin');
|
||||
if (origin && !SQUAREMCP_ALLOWED_ORIGINS.has(origin)) {
|
||||
res.status(403).json({ error: 'Origin not allowed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = getPilotRequestBody((req.body ?? {}) as Record<string, unknown>);
|
||||
const validationError = validatePilotRequest(body);
|
||||
if (validationError) {
|
||||
res.status(400).json({ error: validationError });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
console.log(
|
||||
`[squaremcp] pilot_request requestId=${requestId} company=${body.company} email=${body.email} use_case=${body.use_case}`
|
||||
);
|
||||
|
||||
try {
|
||||
await appendPilotRequestToVault(requestId, body, req);
|
||||
res.status(201).json({ ok: true, request_id: requestId });
|
||||
} catch (error) {
|
||||
console.error(`[squaremcp] pilot_request ERROR requestId=${requestId}:`, error);
|
||||
res.status(500).json({ error: 'Failed to store pilot request' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── OAuth Discovery (RFC 8414) ─────────────────────────────────────────────
|
||||
const oauthDiscovery = {
|
||||
issuer: SERVER_URL,
|
||||
authorization_endpoint: `${SERVER_URL}/oauth/authorize`,
|
||||
token_endpoint: `${SERVER_URL}/oauth/token`,
|
||||
registration_endpoint: `${SERVER_URL}/oauth/register`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post'],
|
||||
};
|
||||
|
||||
const protectedResourceMetadata = {
|
||||
resource: MCP_RESOURCE_URL,
|
||||
authorization_servers: [SERVER_URL],
|
||||
scopes_supported: ['email', 'obsidian'],
|
||||
bearer_methods_supported: ['header'],
|
||||
resource_documentation: `${SERVER_URL}/tools`,
|
||||
};
|
||||
|
||||
app.get('/.well-known/oauth-authorization-server', (_req, res) => {
|
||||
res.json(oauthDiscovery);
|
||||
});
|
||||
|
||||
app.get('/.well-known/oauth-protected-resource', (_req, res) => {
|
||||
res.json(protectedResourceMetadata);
|
||||
});
|
||||
|
||||
app.get('/.well-known/oauth-protected-resource/mcp', (_req, res) => {
|
||||
res.json(protectedResourceMetadata);
|
||||
});
|
||||
|
||||
app.get('/.well-known/openid-configuration', (_req, res) => {
|
||||
res.json({
|
||||
...oauthDiscovery,
|
||||
scopes_supported: ['openid', 'email', 'profile'],
|
||||
claims_supported: ['sub', 'iss'],
|
||||
});
|
||||
});
|
||||
|
||||
// ── Health ──────────────────────────────────────────────────────────────────
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'hermes-mcp',
|
||||
toolCount: tools.length,
|
||||
transports: ['streamable-http', 'sse'],
|
||||
endpoints: ['/mcp', '/sse', '/tools', '/openapi.json', '/health', '/oauth/authorize', '/oauth/token', '/oauth/register', '/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource', '/.well-known/ai-plugin.json', '/api/obsidian/search', '/api/obsidian/note', '/api/obsidian/note/append', '/api/obsidian/sync'],
|
||||
});
|
||||
});
|
||||
|
||||
async function main() {
|
||||
await initDatabase();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Hermes MCP server running on port ${PORT}`);
|
||||
console.log(` Streamable HTTP: ${SERVER_URL}/mcp`);
|
||||
console.log(` SSE (legacy): ${SERVER_URL}/sse`);
|
||||
console.log(` Tools manifest: ${SERVER_URL}/tools`);
|
||||
console.log(` Health: ${SERVER_URL}/health`);
|
||||
console.log(` OAuth authorize: ${SERVER_URL}/oauth/authorize`);
|
||||
console.log(` OAuth token: ${SERVER_URL}/oauth/token`);
|
||||
console.log(` OAuth register: ${SERVER_URL}/oauth/register`);
|
||||
console.log(` OAuth discovery: ${SERVER_URL}/.well-known/oauth-authorization-server`);
|
||||
console.log(` Resource meta: ${SERVER_URL}/.well-known/oauth-protected-resource`);
|
||||
console.log(` OIDC discovery: ${SERVER_URL}/.well-known/openid-configuration`);
|
||||
if (API_KEY) {
|
||||
console.log(` Auth: API key + OAuth Bearer tokens accepted`);
|
||||
} else {
|
||||
console.warn(` Auth: NO API KEY SET — consider setting MCP_API_KEY`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
554
src/manifest.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
const SCHEMA_VERSION = '1.0.0';
|
||||
|
||||
export function getOpenApiSpec(serverUrl: string) {
|
||||
return {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'Hermes',
|
||||
description: 'Personal AI tools: Obsidian vault (create, read, update, search notes) and email operations across multiple accounts.',
|
||||
version: '1.0.0',
|
||||
},
|
||||
servers: [{ url: serverUrl }],
|
||||
security: [{ bearerAuth: [] }],
|
||||
components: {
|
||||
schemas: {},
|
||||
securitySchemes: {
|
||||
bearerAuth: { type: 'http', scheme: 'bearer' },
|
||||
},
|
||||
},
|
||||
paths: {
|
||||
'/api/obsidian/search': {
|
||||
get: {
|
||||
operationId: 'obsidian_search_notes',
|
||||
summary: 'Search Obsidian notes',
|
||||
description: 'Full-text search across the vault by content, title, or tags. Returns matching note paths, excerpts, and metadata.',
|
||||
parameters: [
|
||||
{ name: 'query', in: 'query', required: true, schema: { type: 'string' }, description: 'Text to search for in note content or title' },
|
||||
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 10 }, description: 'Maximum number of results to return' },
|
||||
{ name: 'path_filter', in: 'query', schema: { type: 'string' }, description: 'Only return notes whose path contains this string (e.g. "Daily Notes" or "SquareMCP")' },
|
||||
{ name: 'tags', in: 'query', schema: { type: 'string' }, description: 'Comma-separated list of tags all results must have' },
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Matching notes',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative vault path — use this as-is in other calls' },
|
||||
title: { type: 'string' },
|
||||
excerpt: { type: 'string', description: 'Matched text excerpt (~200 chars)' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
modified_date: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/obsidian/note': {
|
||||
get: {
|
||||
operationId: 'obsidian_read_note',
|
||||
summary: 'Read an Obsidian note',
|
||||
description: 'Retrieve the full markdown content of a specific note by its vault path.',
|
||||
parameters: [
|
||||
{ name: 'path', in: 'query', required: true, schema: { type: 'string' }, description: 'Relative vault path (e.g. "CMG Project/SoFi First Principles Readout for CMG and AIO.md")' },
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Full note',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
content: { type: 'string', description: 'Full markdown content' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
links: { type: 'array', items: { type: 'string' }, description: 'Internal [[wiki-links]] found in note' },
|
||||
modified_date: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'404': { description: 'Note not found' },
|
||||
},
|
||||
},
|
||||
put: {
|
||||
operationId: 'obsidian_update_note',
|
||||
summary: 'Overwrite an Obsidian note',
|
||||
description: 'Replace the entire content of a note with new markdown. Creates the note if it does not exist. Use this for rewrites or structural edits; use the append endpoint for adding content.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['path', 'content'],
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative vault path' },
|
||||
content: { type: 'string', description: 'Full markdown content — replaces existing content entirely' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Note written',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
path: { type: 'string' },
|
||||
bytes_written: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/obsidian/note/append': {
|
||||
post: {
|
||||
operationId: 'obsidian_append_to_note',
|
||||
summary: 'Append to an Obsidian note',
|
||||
description: 'Add markdown content to the end of a note, optionally under an H2 section header. Creates the note if it does not exist.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['path', 'content'],
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative vault path (e.g. "Daily Notes/2026-04-28.md")' },
|
||||
content: { type: 'string', description: 'Markdown content to append' },
|
||||
header: { type: 'string', description: 'Optional H2 section header inserted before the content' },
|
||||
create_if_missing: { type: 'boolean', default: true, description: 'Create the note if it does not exist' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Content appended',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
path: { type: 'string' },
|
||||
bytes_written: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/obsidian/sync': {
|
||||
get: {
|
||||
operationId: 'obsidian_sync_status',
|
||||
summary: 'Check vault sync status',
|
||||
description: 'Returns Syncthing sync state and vault statistics.',
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Sync status',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', description: 'Syncthing state (idle, syncing, error, etc.)' },
|
||||
last_sync: { type: 'string', format: 'date-time', nullable: true },
|
||||
vault_size: { type: 'integer', description: 'Total vault size in bytes' },
|
||||
pending_changes: { type: 'integer', description: 'Files awaiting sync' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ACCOUNT_PARAM_SCHEMA = {
|
||||
account: {
|
||||
type: 'string',
|
||||
enum: ['yahoo', 'fetcherpay', 'garfield', 'sales', 'leads', 'founder', 'gmail'],
|
||||
description:
|
||||
'Which mailbox to use: "yahoo" (gheron01@yahoo.com), "fetcherpay" (garfield.heron@fetcherpay.com), "garfield" (garfield@fetcherpay.com), "sales" (sales@fetcherpay.com), "leads" (leads@fetcherpay.com), "founder" (founder@fetcherpay.com), or "gmail" (Gmail account). Defaults to "yahoo".',
|
||||
},
|
||||
};
|
||||
|
||||
export function getManifest(serverUrl: string, authEnabled: boolean) {
|
||||
return {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
oauth_endpoints: {
|
||||
issuer: serverUrl,
|
||||
authorization_server_base: serverUrl,
|
||||
authorization: `${serverUrl}/oauth/authorize`,
|
||||
token: `${serverUrl}/oauth/token`,
|
||||
registration: `${serverUrl}/oauth/register`,
|
||||
},
|
||||
server: {
|
||||
name: 'hermes-mcp',
|
||||
version: '1.0.0',
|
||||
url: serverUrl,
|
||||
auth: authEnabled
|
||||
? {
|
||||
type: 'apiKey',
|
||||
header: 'x-api-key',
|
||||
location: 'header',
|
||||
note: 'Also accepted as ?key= query parameter',
|
||||
}
|
||||
: { type: 'none' },
|
||||
},
|
||||
tools: [
|
||||
// ── Email tools ─────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'get_profile',
|
||||
category: 'email',
|
||||
description: 'Get the email account profile (email address and display name)',
|
||||
when_to_use:
|
||||
'User asks for their email address, display name, or account identity before sending or reading messages.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { ...ACCOUNT_PARAM_SCHEMA },
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: { type: 'string', description: 'Full email address' },
|
||||
name: { type: 'string', description: 'Username portion of the email' },
|
||||
account: { type: 'string', description: 'Account alias used' },
|
||||
},
|
||||
},
|
||||
examples: [{ account: 'yahoo' }],
|
||||
},
|
||||
{
|
||||
name: 'search_messages',
|
||||
category: 'email',
|
||||
description: 'Search email messages by keyword, sender, or subject across the selected account',
|
||||
when_to_use:
|
||||
'User asks to find, look up, or search for emails, messages, or correspondence in Yahoo or FetcherPay mailboxes.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
required: ['q'],
|
||||
properties: {
|
||||
q: {
|
||||
type: 'string',
|
||||
description: 'Search query — can be a keyword, from:email, or subject:text',
|
||||
},
|
||||
maxResults: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of messages to return',
|
||||
default: 20,
|
||||
},
|
||||
...ACCOUNT_PARAM_SCHEMA,
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
uid: { type: 'number' },
|
||||
messageId: { type: 'string' },
|
||||
subject: { type: 'string' },
|
||||
from: { type: 'string' },
|
||||
date: { type: 'string', format: 'date-time' },
|
||||
seen: { type: 'boolean' },
|
||||
size: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
examples: [{ q: 'invoice', maxResults: 10, account: 'fetcherpay' }],
|
||||
},
|
||||
{
|
||||
name: 'read_message',
|
||||
category: 'email',
|
||||
description: 'Read a full email message including body text by its UID',
|
||||
when_to_use:
|
||||
'User asks to read, open, show, or summarize a specific email message identified by UID from search results.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
required: ['uid'],
|
||||
properties: {
|
||||
uid: {
|
||||
type: 'number',
|
||||
description: 'Message UID returned by search_messages',
|
||||
},
|
||||
...ACCOUNT_PARAM_SCHEMA,
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
uid: { type: 'number' },
|
||||
messageId: { type: 'string' },
|
||||
subject: { type: 'string' },
|
||||
from: { type: 'string' },
|
||||
to: { type: 'string' },
|
||||
date: { type: 'string', format: 'date-time' },
|
||||
body: { type: 'string', description: 'Plain-text body (HTML stripped)' },
|
||||
seen: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
examples: [{ uid: 12345, account: 'yahoo' }],
|
||||
},
|
||||
{
|
||||
name: 'list_folders',
|
||||
category: 'email',
|
||||
description: 'List all email folders and mailboxes for the selected account',
|
||||
when_to_use:
|
||||
'User asks what folders exist, wants to know mailbox structure, or needs folder names for email organization.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { ...ACCOUNT_PARAM_SCHEMA },
|
||||
},
|
||||
returns: {
|
||||
type: 'array',
|
||||
items: { type: 'string', description: 'Folder path (e.g. INBOX, Sent)' },
|
||||
},
|
||||
examples: [{ account: 'yahoo' }],
|
||||
},
|
||||
{
|
||||
name: 'create_draft',
|
||||
category: 'email',
|
||||
description: 'Create a draft email saved to the Drafts folder',
|
||||
when_to_use:
|
||||
'User wants to compose or save a draft email without sending it immediately.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
required: ['to', 'subject', 'body'],
|
||||
properties: {
|
||||
to: { type: 'string', description: 'Recipient email address' },
|
||||
subject: { type: 'string', description: 'Email subject line' },
|
||||
body: { type: 'string', description: 'Email body in plain text' },
|
||||
...ACCOUNT_PARAM_SCHEMA,
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'string',
|
||||
description: 'Confirmation message indicating the draft was created',
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
to: 'partner@example.com',
|
||||
subject: 'Meeting follow-up',
|
||||
body: 'Thanks for the call today.',
|
||||
account: 'fetcherpay',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'send_email',
|
||||
category: 'email',
|
||||
description: 'Send an email immediately via SMTP',
|
||||
when_to_use:
|
||||
'User asks to send an email, message, or mail to someone right now.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
required: ['to', 'subject', 'body'],
|
||||
properties: {
|
||||
to: { type: 'string', description: 'Recipient email address' },
|
||||
subject: { type: 'string', description: 'Email subject line' },
|
||||
body: { type: 'string', description: 'Email body in plain text' },
|
||||
...ACCOUNT_PARAM_SCHEMA,
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'string',
|
||||
description: 'SMTP message ID confirming the send',
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
to: 'team@fetcherpay.com',
|
||||
subject: 'Weekly update',
|
||||
body: 'Here is the weekly summary.',
|
||||
account: 'garfield',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Obsidian tools ──────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'obsidian_search_notes',
|
||||
category: 'obsidian',
|
||||
description: 'Full-text search across the Obsidian vault by content, tags, or title',
|
||||
when_to_use:
|
||||
'User mentions "my notes", "in obsidian", "I wrote about", asks about personal knowledge, or references past notes.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
required: ['query'],
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Text to search for in note content or title',
|
||||
},
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Filter by Obsidian tags (all must match)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Max results to return',
|
||||
default: 10,
|
||||
},
|
||||
path_filter: {
|
||||
type: 'string',
|
||||
description: 'Only return notes whose path contains this string (e.g. "Daily Notes")',
|
||||
},
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative file path in vault' },
|
||||
title: { type: 'string' },
|
||||
excerpt: { type: 'string', description: 'Matched text excerpt (~200 chars)' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
modified_date: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
examples: [{ query: 'funding strategy', limit: 5 }],
|
||||
},
|
||||
{
|
||||
name: 'obsidian_read_note',
|
||||
category: 'obsidian',
|
||||
description: 'Retrieve the complete content of a specific Obsidian note by path or title',
|
||||
when_to_use:
|
||||
'User asks to read, open, show, or see a specific note by path or title.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
required: ['path'],
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'File path relative to vault root (e.g. "Daily Notes/2026-04-15.md") or just the note title/filename',
|
||||
},
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
content: { type: 'string', description: 'Full markdown content' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
links: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Internal wiki-style links extracted from content',
|
||||
},
|
||||
modified_date: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
examples: [{ path: 'Projects/FetcherPay.md' }],
|
||||
},
|
||||
{
|
||||
name: 'obsidian_append_to_note',
|
||||
category: 'obsidian',
|
||||
description: 'Append markdown content to any Obsidian note, creating it if missing',
|
||||
when_to_use:
|
||||
'User wants to log, save, journal, or add content to daily notes or specific vault files.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
required: ['path', 'content'],
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'File path relative to vault root (e.g. "Daily Notes/2026-04-15.md")',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Markdown content to append',
|
||||
},
|
||||
create_if_missing: {
|
||||
type: 'boolean',
|
||||
description: 'Create the note if it does not exist',
|
||||
default: true,
|
||||
},
|
||||
header: {
|
||||
type: 'string',
|
||||
description: 'Optional H2 section header to insert before the content',
|
||||
},
|
||||
},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
path: { type: 'string' },
|
||||
bytes_written: { type: 'number' },
|
||||
},
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
path: 'Daily Notes/2026-04-15.md',
|
||||
content: '- Met with investor at 2pm',
|
||||
header: 'Meetings',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'obsidian_sync_status',
|
||||
category: 'obsidian',
|
||||
description: 'Check Syncthing sync status and vault statistics',
|
||||
when_to_use:
|
||||
'User asks if their vault is synced, up to date, or about device connectivity for Obsidian notes.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
returns: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', description: 'Syncthing state (e.g. idle, syncing, error)' },
|
||||
last_sync: {
|
||||
type: ['string', 'null'],
|
||||
format: 'date-time',
|
||||
description: 'ISO timestamp of last state change',
|
||||
},
|
||||
vault_size: { type: 'number', description: 'Total bytes in vault' },
|
||||
pending_changes: { type: 'number', description: 'Files needing sync' },
|
||||
},
|
||||
},
|
||||
examples: [{}],
|
||||
},
|
||||
],
|
||||
categories: {
|
||||
obsidian: {
|
||||
description: 'Personal knowledge management via Obsidian vault',
|
||||
icon: '📝',
|
||||
},
|
||||
email: {
|
||||
description: 'Email operations for Yahoo, FetcherPay, and Gmail accounts',
|
||||
icon: '📧',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
281
src/oauth.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import crypto from 'crypto';
|
||||
import type { RowDataPacket } from 'mysql2/promise';
|
||||
import { getPool, isPoolReady } from './db.js';
|
||||
|
||||
const AUTH_CODE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export interface Client {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
client_name: string;
|
||||
redirect_uris: string[];
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface AuthCode {
|
||||
code: string;
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope?: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
interface Token {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
// Cleanup expired rows every 60 seconds
|
||||
setInterval(async () => {
|
||||
if (!isPoolReady()) return;
|
||||
try {
|
||||
const pool = getPool();
|
||||
await pool.execute('DELETE FROM oauth_auth_codes WHERE expires_at < NOW() OR used = TRUE');
|
||||
await pool.execute('DELETE FROM oauth_tokens WHERE expires_at < NOW()');
|
||||
} catch (err) {
|
||||
console.error('[oauth] Cleanup error:', err);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
export function generateClientId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function generateClientSecret(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export function generateAuthCode(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export function generateAccessToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export async function registerClient(body: {
|
||||
client_name?: string;
|
||||
redirect_uris?: string[];
|
||||
[key: string]: unknown;
|
||||
}): Promise<Client> {
|
||||
const client: Client = {
|
||||
client_id: generateClientId(),
|
||||
client_secret: generateClientSecret(),
|
||||
client_name: body.client_name || 'Unnamed Client',
|
||||
redirect_uris: Array.isArray(body.redirect_uris) ? body.redirect_uris : [],
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
const pool = getPool();
|
||||
await pool.execute(
|
||||
'INSERT INTO oauth_clients (client_id, client_secret, client_name, redirect_urls, grant_types) VALUES (?, ?, ?, ?, ?)',
|
||||
[
|
||||
client.client_id,
|
||||
client.client_secret,
|
||||
client.client_name,
|
||||
JSON.stringify(client.redirect_uris),
|
||||
JSON.stringify(['authorization_code']),
|
||||
]
|
||||
);
|
||||
|
||||
console.log(`[oauth] Registered client ${client.client_id} (${client.client_name})`);
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function getClient(clientId: string): Promise<Client | undefined> {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||
'SELECT * FROM oauth_clients WHERE client_id = ?',
|
||||
[clientId]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const row = rows[0];
|
||||
|
||||
// Update last_used
|
||||
await pool.execute(
|
||||
'UPDATE oauth_clients SET last_used = CURRENT_TIMESTAMP WHERE client_id = ?',
|
||||
[clientId]
|
||||
);
|
||||
|
||||
return {
|
||||
client_id: row.client_id,
|
||||
client_secret: row.client_secret,
|
||||
client_name: row.client_name,
|
||||
redirect_uris: Array.isArray(row.redirect_urls) ? row.redirect_urls : JSON.parse(row.redirect_urls || '[]'),
|
||||
created_at: new Date(row.created_at).getTime(),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[oauth] getClient error:', err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAuthCode(
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
scope?: string
|
||||
): Promise<AuthCode> {
|
||||
const code: AuthCode = {
|
||||
code: generateAuthCode(),
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
expires_at: Date.now() + AUTH_CODE_EXPIRY_MS,
|
||||
};
|
||||
|
||||
const pool = getPool();
|
||||
await pool.execute(
|
||||
'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[code.code, clientId, redirectUri, new Date(code.expires_at)]
|
||||
);
|
||||
|
||||
console.log(`[oauth] Created auth code ${code.code.slice(0, 8)}... for client ${clientId}`);
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForToken(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
code: string,
|
||||
redirectUri: string
|
||||
): Promise<Token | null> {
|
||||
let client: Client | undefined;
|
||||
try {
|
||||
client = await getClient(clientId);
|
||||
} catch (err) {
|
||||
console.error('[oauth] getClient error during token exchange:', err);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!client || client.client_secret !== clientSecret) {
|
||||
console.log('[oauth] Invalid client credentials');
|
||||
return null;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const db = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await db.execute<RowDataPacket[]>(
|
||||
'SELECT * FROM oauth_auth_codes WHERE code = ? AND used = FALSE AND expires_at > NOW()',
|
||||
[code]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
console.log('[oauth] Auth code not found or expired');
|
||||
return null;
|
||||
}
|
||||
|
||||
const authCode = rows[0];
|
||||
if (authCode.client_id !== clientId || authCode.redirect_uri !== redirectUri) {
|
||||
console.log('[oauth] Auth code client/redirect mismatch');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mark auth code as used
|
||||
await db.execute('UPDATE oauth_auth_codes SET used = TRUE WHERE code = ?', [code]);
|
||||
|
||||
const token: Token = {
|
||||
access_token: generateAccessToken(),
|
||||
token_type: 'Bearer',
|
||||
expires_in: TOKEN_EXPIRY_MS / 1000,
|
||||
scope: authCode.scope || undefined,
|
||||
expires_at: Date.now() + TOKEN_EXPIRY_MS,
|
||||
};
|
||||
|
||||
await db.execute(
|
||||
'INSERT INTO oauth_tokens (token, client_id, token_type, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[token.access_token, clientId, 'access', new Date(token.expires_at)]
|
||||
);
|
||||
|
||||
console.log(`[oauth] Issued token ${token.access_token.slice(0, 8)}... for client ${clientId}`);
|
||||
return token;
|
||||
} catch (err) {
|
||||
console.error('[oauth] exchangeCodeForToken error:', err);
|
||||
return null;
|
||||
} finally {
|
||||
db.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateAccessToken(tokenValue: string): Promise<boolean> {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||
'SELECT * FROM oauth_tokens WHERE token = ? AND expires_at > NOW()',
|
||||
[tokenValue]
|
||||
);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[oauth] validateAccessToken error:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthorizeHtml(params: {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
state?: string;
|
||||
scope?: string;
|
||||
}): string {
|
||||
const { client_id, redirect_uri, state, scope } = params;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Authorize Hermes MCP</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
|
||||
.card { background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 400px; width: 100%; }
|
||||
h1 { margin: 0 0 1rem; font-size: 1.5rem; color: #111; }
|
||||
p { color: #555; line-height: 1.5; margin: 0.5rem 0; }
|
||||
.client { font-weight: 600; color: #111; }
|
||||
.scopes { background: #f9f9f9; padding: 0.75rem; border-radius: 8px; margin: 1rem 0; font-size: 0.9rem; color: #333; }
|
||||
.buttons { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
|
||||
button { flex: 1; padding: 0.75rem; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; }
|
||||
.allow { background: #111; color: white; }
|
||||
.deny { background: #e5e5e5; color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Authorize Hermes MCP</h1>
|
||||
<p>Client <span class="client">${escapeHtml(client_id)}</span> wants to access your Hermes tools (email + Obsidian vault).</p>
|
||||
<div class="scopes">Scopes: ${escapeHtml(scope || 'default')}</div>
|
||||
<form method="POST" action="/oauth/authorize">
|
||||
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}">
|
||||
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
|
||||
${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ''}
|
||||
${scope ? `<input type="hidden" name="scope" value="${escapeHtml(scope)}">` : ''}
|
||||
<div class="buttons">
|
||||
<button type="submit" name="action" value="deny" class="deny">Deny</button>
|
||||
<button type="submit" name="action" value="allow" class="allow">Allow</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
46
src/smtp.ts
@@ -26,6 +26,16 @@ function getSmtpTransport(account: Account = 'yahoo') {
|
||||
return fetcherpaySmtpTransport(process.env['LEADS_EMAIL']!, process.env['LEADS_PASSWORD']!);
|
||||
case 'founder':
|
||||
return fetcherpaySmtpTransport(process.env['FOUNDER_EMAIL']!, process.env['FOUNDER_PASSWORD']!);
|
||||
case 'gmail':
|
||||
return nodemailer.createTransport({
|
||||
host: 'smtp.gmail.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: {
|
||||
user: process.env['GMAIL_EMAIL']!,
|
||||
pass: process.env['GMAIL_APP_PASSWORD']!,
|
||||
},
|
||||
});
|
||||
default:
|
||||
return nodemailer.createTransport({
|
||||
host: 'smtp.mail.yahoo.com',
|
||||
@@ -47,6 +57,7 @@ function getSenderEmail(account: Account = 'yahoo'): string {
|
||||
sales: process.env['SALES_EMAIL'] ?? '',
|
||||
leads: process.env['LEADS_EMAIL'] ?? '',
|
||||
founder: process.env['FOUNDER_EMAIL'] ?? '',
|
||||
gmail: process.env['GMAIL_EMAIL'] ?? '',
|
||||
};
|
||||
return emailMap[account] ?? '';
|
||||
}
|
||||
@@ -88,17 +99,30 @@ export async function createDraft(
|
||||
leads: { user: process.env['LEADS_EMAIL']!, pass: process.env['LEADS_PASSWORD']! },
|
||||
founder: { user: process.env['FOUNDER_EMAIL']!, pass: process.env['FOUNDER_PASSWORD']! },
|
||||
};
|
||||
const imapConfig = fetcherpayImapAccounts[account]
|
||||
? { ...fetcherpayImapBase, auth: fetcherpayImapAccounts[account]! }
|
||||
: {
|
||||
host: 'imap.mail.yahoo.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env['YAHOO_EMAIL']!,
|
||||
pass: process.env['YAHOO_APP_PASSWORD']!,
|
||||
},
|
||||
};
|
||||
let imapConfig;
|
||||
if (fetcherpayImapAccounts[account]) {
|
||||
imapConfig = { ...fetcherpayImapBase, auth: fetcherpayImapAccounts[account]! };
|
||||
} else if (account === 'gmail') {
|
||||
imapConfig = {
|
||||
host: 'imap.gmail.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env['GMAIL_EMAIL']!,
|
||||
pass: process.env['GMAIL_APP_PASSWORD']!,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
imapConfig = {
|
||||
host: 'imap.mail.yahoo.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env['YAHOO_EMAIL']!,
|
||||
pass: process.env['YAHOO_APP_PASSWORD']!,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const client = new ImapFlow(imapConfig);
|
||||
await client.connect();
|
||||
|
||||
133
src/tools.ts
@@ -1,12 +1,13 @@
|
||||
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { searchMessages, readMessage, getProfile, listFolders, type Account } from './imap.js';
|
||||
import { sendEmail, createDraft } from './smtp.js';
|
||||
import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.js';
|
||||
|
||||
const ACCOUNT_PARAM = {
|
||||
account: {
|
||||
type: 'string',
|
||||
enum: ['yahoo', 'fetcherpay', 'garfield', 'sales', 'leads', 'founder'],
|
||||
description: 'Which mailbox to use: "yahoo" (gheron01@yahoo.com), "fetcherpay" (garfield.heron@fetcherpay.com), "garfield" (garfield@fetcherpay.com), "sales" (sales@fetcherpay.com), "leads" (leads@fetcherpay.com), or "founder" (founder@fetcherpay.com). Defaults to "yahoo".',
|
||||
enum: ['yahoo', 'fetcherpay', 'garfield', 'sales', 'leads', 'founder', 'gmail'],
|
||||
description: 'Which mailbox to use: "yahoo" (gheron01@yahoo.com), "fetcherpay" (garfield.heron@fetcherpay.com), "garfield" (garfield@fetcherpay.com), "sales" (sales@fetcherpay.com), "leads" (leads@fetcherpay.com), "founder" (founder@fetcherpay.com), or "gmail" (Gmail account). Defaults to "yahoo".',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +28,7 @@ export const tools: Tool[] = [
|
||||
properties: {
|
||||
q: { type: 'string', description: 'Search query (keyword, from:email, subject:text)' },
|
||||
maxResults: { type: 'number', description: 'Max messages to return (default 20)' },
|
||||
folder: { type: 'string', description: 'Folder to search (default INBOX). For Gmail, [Gmail]/All Mail searches everywhere.' },
|
||||
...ACCOUNT_PARAM,
|
||||
},
|
||||
required: ['q'],
|
||||
@@ -39,6 +41,7 @@ export const tools: Tool[] = [
|
||||
type: 'object',
|
||||
properties: {
|
||||
uid: { type: 'number', description: 'Message UID from search results' },
|
||||
folder: { type: 'string', description: 'Folder the message was found in (default INBOX). Use the folder value from search_results.' },
|
||||
...ACCOUNT_PARAM,
|
||||
},
|
||||
required: ['uid'],
|
||||
@@ -80,6 +83,97 @@ export const tools: Tool[] = [
|
||||
required: ['to', 'subject', 'body'],
|
||||
},
|
||||
},
|
||||
|
||||
// ── Obsidian tools ────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'obsidian_search_notes',
|
||||
description:
|
||||
'Search across the Obsidian vault by content, tags, or title. Use when the user references "my notes", "in obsidian", "I wrote about", or needs personal knowledge retrieval. Returns note paths relative to the vault root (e.g. "Daily Notes/2026-04-15.md"). Always use these exact relative paths when calling obsidian_read_note or obsidian_append_to_note.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Text to search for in note content or title' },
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Filter by Obsidian tags (all must match)',
|
||||
},
|
||||
limit: { type: 'number', description: 'Max results to return (default 10)' },
|
||||
path_filter: {
|
||||
type: 'string',
|
||||
description: 'Only return notes whose relative vault path contains this string (e.g. "Daily Notes")',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'obsidian_read_note',
|
||||
description:
|
||||
'Retrieve the full content of a specific Obsidian note by relative vault path. Use when the user asks to read, open, or show a specific note. The path must be relative to the vault root (e.g. "Daily Notes/2026-04-15.md"). Do not use absolute filesystem paths.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'File path relative to the vault root (e.g. "Daily Notes/2026-04-15.md") or just the note title/filename',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'obsidian_append_to_note',
|
||||
description:
|
||||
'Append markdown content to an Obsidian daily note or any specific vault note. Use for logging, journaling, capturing insights from email, or recording action items in your notes. Creates the note if it does not exist. The path must be relative to the vault root (e.g. "Daily Notes/2026-04-15.md"). Do not use absolute filesystem paths.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'File path relative to the vault root (e.g. "Daily Notes/2026-04-15.md")',
|
||||
},
|
||||
content: { type: 'string', description: 'Markdown content to append' },
|
||||
create_if_missing: {
|
||||
type: 'boolean',
|
||||
description: 'Create the note if it does not exist (default true)',
|
||||
},
|
||||
header: {
|
||||
type: 'string',
|
||||
description: 'Optional H2 section header to add before the content',
|
||||
},
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'obsidian_update_note',
|
||||
description:
|
||||
'Overwrite the entire content of an Obsidian note with new markdown. Use when you need to replace, rewrite, or structurally edit an existing note rather than append to it. Creates the note if it does not exist.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'File path relative to vault root (e.g. "Daily Notes/2026-04-15.md")',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Full markdown content to write — replaces existing content entirely',
|
||||
},
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'obsidian_sync_status',
|
||||
description:
|
||||
'Check the sync status of the Obsidian vault via Syncthing. Use when the user asks if their notes are synced or up to date.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function acct(args: Record<string, unknown>): Account {
|
||||
@@ -101,11 +195,11 @@ export async function handleToolCall(
|
||||
break;
|
||||
|
||||
case 'search_messages':
|
||||
result = await searchMessages(args.q as string, (args.maxResults as number) ?? 20, acct(args));
|
||||
result = await searchMessages(args.q as string, (args.maxResults as number) ?? 20, acct(args), args.folder as string | undefined);
|
||||
break;
|
||||
|
||||
case 'read_message':
|
||||
result = await readMessage(args.uid as number, acct(args));
|
||||
result = await readMessage(args.uid as number, acct(args), args.folder as string | undefined);
|
||||
break;
|
||||
|
||||
case 'list_folders':
|
||||
@@ -120,6 +214,37 @@ export async function handleToolCall(
|
||||
result = await sendEmail(args.to as string, args.subject as string, args.body as string, acct(args));
|
||||
break;
|
||||
|
||||
// ── Obsidian ──────────────────────────────────────────────────────────
|
||||
case 'obsidian_search_notes':
|
||||
result = await searchNotes(
|
||||
args.query as string,
|
||||
args.tags as string[] | undefined,
|
||||
(args.limit as number) ?? 10,
|
||||
args.path_filter as string | undefined
|
||||
);
|
||||
break;
|
||||
|
||||
case 'obsidian_read_note':
|
||||
result = await getNote(args.path as string);
|
||||
break;
|
||||
|
||||
case 'obsidian_append_to_note':
|
||||
result = await appendToNote(
|
||||
args.path as string,
|
||||
args.content as string,
|
||||
(args.create_if_missing as boolean) ?? true,
|
||||
args.header as string | undefined
|
||||
);
|
||||
break;
|
||||
|
||||
case 'obsidian_update_note':
|
||||
result = await updateNote(args.path as string, args.content as string);
|
||||
break;
|
||||
|
||||
case 'obsidian_sync_status':
|
||||
result = await getSyncStatus();
|
||||
break;
|
||||
|
||||
// Legacy Yahoo-prefixed names — keep working for any cached Claude sessions
|
||||
case 'yahoo_get_profile':
|
||||
result = await getProfile('yahoo');
|
||||
|
||||
32
test-gmail-imap.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ImapFlow } from 'imapflow';
|
||||
|
||||
const client = new ImapFlow({
|
||||
host: 'imap.gmail.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.GMAIL_EMAIL || 'garfield.heron@gmail.com',
|
||||
pass: process.env.GMAIL_APP_PASSWORD || 'ldmk duee movy hruy',
|
||||
},
|
||||
logger: false,
|
||||
});
|
||||
|
||||
try {
|
||||
console.log('Connecting to Gmail IMAP...');
|
||||
await client.connect();
|
||||
console.log('Connected!');
|
||||
|
||||
const mailbox = await client.mailboxOpen('INBOX');
|
||||
console.log('Mailbox opened:', mailbox.path, 'messages:', mailbox.exists);
|
||||
|
||||
const criteria = { or: [{ subject: 'test' }, { from: 'test' }] };
|
||||
const uids = await client.search(criteria, { uid: true });
|
||||
console.log('Search results:', uids);
|
||||
|
||||
await client.logout();
|
||||
console.log('Done');
|
||||
} catch (err) {
|
||||
console.error('ERROR:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
56
test-gmail-indeed.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ImapFlow } from 'imapflow';
|
||||
|
||||
const client = new ImapFlow({
|
||||
host: 'imap.gmail.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.GMAIL_EMAIL,
|
||||
pass: process.env.GMAIL_APP_PASSWORD,
|
||||
},
|
||||
logger: false,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
// Test 1: Search INBOX for indeed
|
||||
console.log('=== Searching INBOX for "indeed" ===');
|
||||
await client.mailboxOpen('INBOX');
|
||||
let uids = await client.search({ gmailraw: 'indeed' }, { uid: true });
|
||||
console.log('INBOX results:', Array.isArray(uids) ? uids.length : 0, 'messages');
|
||||
if (Array.isArray(uids) && uids.length > 0) {
|
||||
const recent = uids.slice(-5);
|
||||
for await (const msg of client.fetch(recent, { envelope: true }, { uid: true })) {
|
||||
const env = msg.envelope;
|
||||
console.log(` [${msg.uid}] ${env?.subject} | from: ${env?.from?.[0]?.address}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: Search All Mail for indeed
|
||||
console.log('\n=== Searching [Gmail]/All Mail for "indeed" ===');
|
||||
await client.mailboxOpen('[Gmail]/All Mail');
|
||||
uids = await client.search({ gmailraw: 'indeed' }, { uid: true });
|
||||
console.log('All Mail results:', Array.isArray(uids) ? uids.length : 0, 'messages');
|
||||
if (Array.isArray(uids) && uids.length > 0) {
|
||||
const recent = uids.slice(-10);
|
||||
for await (const msg of client.fetch(recent, { envelope: true }, { uid: true })) {
|
||||
const env = msg.envelope;
|
||||
console.log(` [${msg.uid}] ${env?.subject} | from: ${env?.from?.[0]?.address}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Search All Mail for from:indeed
|
||||
console.log('\n=== Searching [Gmail]/All Mail for "from:donotreply@jobalert.indeed.com" ===');
|
||||
uids = await client.search({ gmailraw: 'from:donotreply@jobalert.indeed.com' }, { uid: true });
|
||||
console.log('All Mail from:indeed results:', Array.isArray(uids) ? uids.length : 0, 'messages');
|
||||
if (Array.isArray(uids) && uids.length > 0) {
|
||||
const recent = uids.slice(-5);
|
||||
for await (const msg of client.fetch(recent, { envelope: true }, { uid: true })) {
|
||||
const env = msg.envelope;
|
||||
console.log(` [${msg.uid}] ${env?.subject} | from: ${env?.from?.[0]?.address}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
16
test-gmail-search-with-dotenv.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'dotenv/config';
|
||||
import { searchMessages, getProfile } from './dist/imap.js';
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log('GMAIL_EMAIL env:', process.env.GMAIL_EMAIL ? 'set' : 'NOT SET');
|
||||
console.log('GMAIL_APP_PASSWORD env:', process.env.GMAIL_APP_PASSWORD ? 'set' : 'NOT SET');
|
||||
console.log('Profile:', await getProfile('gmail'));
|
||||
const results = await searchMessages('test', 5, 'gmail');
|
||||
console.log('Results:', JSON.stringify(results, null, 2));
|
||||
} catch (err) {
|
||||
console.error('ERROR:', err.message);
|
||||
console.error(err.stack);
|
||||
}
|
||||
}
|
||||
main();
|
||||
13
test-gmail-search.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import { searchMessages, getProfile } from './dist/imap.js';
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log('Profile:', await getProfile('gmail'));
|
||||
const results = await searchMessages('test', 5, 'gmail');
|
||||
console.log('Results:', JSON.stringify(results, null, 2));
|
||||
} catch (err) {
|
||||
console.error('ERROR:', err.message);
|
||||
console.error(err.stack);
|
||||
}
|
||||
}
|
||||
main();
|
||||
41
test-indeed-today.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ImapFlow } from 'imapflow';
|
||||
|
||||
const client = new ImapFlow({
|
||||
host: 'imap.gmail.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.GMAIL_EMAIL,
|
||||
pass: process.env.GMAIL_APP_PASSWORD,
|
||||
},
|
||||
logger: false,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
await client.mailboxOpen('[Gmail]/All Mail');
|
||||
const uids = await client.search({ gmailraw: 'from:donotreply@jobalert.indeed.com after:2026/04/21' }, { uid: true });
|
||||
console.log('Indeed emails today:', Array.isArray(uids) ? uids.length : 0, 'messages\n');
|
||||
if (Array.isArray(uids) && uids.length > 0) {
|
||||
for await (const msg of client.fetch(uids, { envelope: true }, { uid: true })) {
|
||||
const env = msg.envelope;
|
||||
console.log(`- ${env?.subject}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No emails found. Trying broader search for "indeed" today...');
|
||||
const uids2 = await client.search({ gmailraw: 'indeed after:2026/04/21' }, { uid: true });
|
||||
console.log('Broader "indeed" today:', Array.isArray(uids2) ? uids2.length : 0, 'messages\n');
|
||||
if (Array.isArray(uids2) && uids2.length > 0) {
|
||||
for await (const msg of client.fetch(uids2, { envelope: true }, { uid: true })) {
|
||||
const env = msg.envelope;
|
||||
const from = env?.from?.[0]?.address ?? '';
|
||||
if (from.includes('indeed')) {
|
||||
console.log(`- ${env?.subject} | from: ${from}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
35
test-k8s-mcp.sh
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
API_KEY="114521f3f9e6858480d6269446a446ef"
|
||||
URL="https://hermes.fetcherpay.com/mcp"
|
||||
|
||||
# Initialize
|
||||
INIT_RESP=$(curl -s -D - -X POST "$URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "x-api-key: $API_KEY" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}')
|
||||
|
||||
SESSION_ID=$(echo "$INIT_RESP" | grep -i "mcp-session-id:" | awk '{print $2}' | tr -d '\r')
|
||||
echo "SESSION_ID: $SESSION_ID"
|
||||
|
||||
# Call get_profile for gmail
|
||||
PROFILE_RESP=$(curl -s -X POST "$URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "x-api-key: $API_KEY" \
|
||||
-H "mcp-session-id: $SESSION_ID" \
|
||||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_profile","arguments":{"account":"gmail"}}}')
|
||||
|
||||
echo "PROFILE: $PROFILE_RESP"
|
||||
|
||||
# Call search_messages for gmail
|
||||
SEARCH_RESP=$(curl -s -X POST "$URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "x-api-key: $API_KEY" \
|
||||
-H "mcp-session-id: $SESSION_ID" \
|
||||
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_messages","arguments":{"q":"test","account":"gmail","maxResults":2}}}')
|
||||
|
||||
echo "SEARCH: $SEARCH_RESP"
|
||||
49
test-local-mcp.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
async function main() {
|
||||
// 1. Initialize MCP session
|
||||
const initRes = await fetch('http://localhost:3456/mcp', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'x-api-key': '114521f3f9e6858480d6269446a446ef'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }
|
||||
})
|
||||
});
|
||||
const initData = await initRes.json();
|
||||
console.log('Init:', JSON.stringify(initData, null, 2));
|
||||
const sessionId = initRes.headers.get('mcp-session-id');
|
||||
console.log('Session ID:', sessionId);
|
||||
|
||||
if (!sessionId) {
|
||||
console.error('No session ID returned');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Call search_messages for gmail
|
||||
const searchRes = await fetch('http://localhost:3456/mcp', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'x-api-key': '114521f3f9e6858480d6269446a446ef',
|
||||
'mcp-session-id': sessionId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_messages',
|
||||
arguments: { q: 'test', account: 'gmail', maxResults: 3 }
|
||||
}
|
||||
})
|
||||
});
|
||||
const searchData = await searchRes.json();
|
||||
console.log('Search result:', JSON.stringify(searchData, null, 2));
|
||||
}
|
||||
main().catch(console.error);
|
||||
36
test-mcp-curl.sh
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
API_KEY="114521f3f9e6858480d6269446a446ef"
|
||||
URL="http://localhost:3456/mcp"
|
||||
|
||||
# Initialize
|
||||
INIT_RESP=$(curl -s -D - -X POST "$URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "x-api-key: $API_KEY" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}')
|
||||
|
||||
echo "=== INIT RESPONSE ==="
|
||||
echo "$INIT_RESP"
|
||||
echo ""
|
||||
|
||||
SESSION_ID=$(echo "$INIT_RESP" | grep -i "mcp-session-id:" | awk '{print $2}' | tr -d '\r')
|
||||
echo "SESSION_ID: $SESSION_ID"
|
||||
|
||||
if [ -z "$SESSION_ID" ]; then
|
||||
echo "No session ID, trying to parse body"
|
||||
echo "$INIT_RESP" | tail -1 | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Call get_profile for gmail
|
||||
PROFILE_RESP=$(curl -s -D - -X POST "$URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "x-api-key: $API_KEY" \
|
||||
-H "mcp-session-id: $SESSION_ID" \
|
||||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_profile","arguments":{"account":"gmail"}}}')
|
||||
|
||||
echo "=== PROFILE RESPONSE ==="
|
||||
echo "$PROFILE_RESP"
|
||||
15
test-mcp-search.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
API_KEY="114521f3f9e6858480d6269446a446ef"
|
||||
URL="http://localhost:3456/mcp"
|
||||
SESSION_ID="f899450c-97ff-4763-81c3-a77ad3acf78b"
|
||||
|
||||
SEARCH_RESP=$(curl -s -D - -X POST "$URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "x-api-key: $API_KEY" \
|
||||
-H "mcp-session-id: $SESSION_ID" \
|
||||
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_messages","arguments":{"q":"test","account":"gmail","maxResults":3}}}')
|
||||
|
||||
echo "$SEARCH_RESP"
|
||||
12
test-tool-direct.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'dotenv/config';
|
||||
import { handleToolCall } from './dist/tools.js';
|
||||
|
||||
async function main() {
|
||||
// Test via the same path the server uses
|
||||
const result = await handleToolCall('search_messages', { q: 'test', account: 'gmail', maxResults: 3 });
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
main().catch(err => {
|
||||
console.error('ERROR:', err.message);
|
||||
console.error(err.stack);
|
||||
});
|
||||