Skip to content

Commit 545cc58

Browse files
committed
deploy: full-stack Render blueprint (Qdrant + Ollama embed + token-gated MCP)
The "Deploy to Render" button now provisions the whole backend, not just the demo deck. render.yaml grows from one service to four: - ats-qdrant (pserv + disk) — vector search memory, private - ats-ollama (pserv + disk) — nomic-embed-text embedding engine, private - ats-mcp (web, Docker) — public, bearer-token-gated MCP over /mcp + /sse - ats-operator-backend — unchanged demo deck Safety: deploy/mcp/auth-gateway.mjs is the only public port; every request needs Authorization: Bearer <ATS_MCP_TOKEN> (generateValue), /healthz open. It fronts mcp-proxy@6.5.1 (stdio -> HTTP/SSE, localhost only). Qdrant and Ollama have no public address; ats-mcp reaches them via fromService host refs over the private network. start.sh materializes ~/.config/ats from TICKTICK_ACCESS_TOKEN (the adapter has no env-token path). Also: deploy/README.md operator guide, README "Deploy it yourself" rewritten for the multi-service stack, and the operator-deck demo card swapped off a real-looking ticket scheme to a clearly fictional one.
1 parent e2c0878 commit 545cc58

9 files changed

Lines changed: 420 additions & 21 deletions

File tree

README.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,9 @@ flowchart TB
158158
deck["Operator deck — the swipe UI"]
159159
end
160160
161-
subgraph HOST["VPS or Render · the part you deploy"]
162-
agent["AI agent + ATS core<br/>MCP server · CLI · suggestion engine"]
163-
search["Qdrant + Ollama<br/>optional semantic search"]
161+
subgraph HOST["Render · the part you deploy (one click)"]
162+
mcp["MCP server<br/>your agent connects here · token-gated"]
163+
search["Search memory + embedding engine<br/>Qdrant + Ollama (nomic-embed)"]
164164
end
165165
166166
subgraph TASKS["Your tools · already in the cloud"]
@@ -171,27 +171,28 @@ flowchart TB
171171
end
172172
173173
user --> deck
174-
deck -->|"approve · reject · modify"| agent
175-
agent --> tt & nt & ob & gg
176-
agent -.->|"rank"| search
174+
deck -->|"approve · reject · modify"| mcp
175+
mcp --> tt & nt & ob & gg
176+
mcp -.->|"rank"| search
177177
```
178178

179179
- **The operator deck (the app on your phone)** lives on **Cloudflare Pages** — free, always on, nothing to manage.
180-
- **The backend (the agent that reads your tasks and proposes the next action)** runs on a **VPS or Render**. This is the only part you deploy.
180+
- **The backend you deploy** runs on **Render**. One click gives you the whole thing: the **MCP server** your agent connects to, plus its own **search memory** (Qdrant) and **embedding engine** (Ollama) so it can find tasks by meaning, not just keywords.
181181
- **Your task systems** (TickTick, Notion, Obsidian, Gmail/Calendar) are already in the cloud — ATS just connects to them.
182182

183183
### Deploy the backend in one click
184184

185185
No terminal needed:
186186

187-
1. Click the button. It opens **Render** (a hosting service with a free tier).
188-
2. Sign in with GitHub and confirm — Render reads the blueprint in this repo and builds everything for you.
189-
3. In a few minutes you get a URL like `https://ats-operator-backend.onrender.com`. That's your backend.
190-
4. It starts in **demo mode** so you see it working right away. To connect your own tasks, open the service's **Environment** tab in Render, set `DECK_DEMO` to `0`, and add your task-system token (see [the operator-deck guide](examples/operator-deck/)).
187+
1. Click the button. It opens **Render** (a hosting service). Sign in with GitHub.
188+
2. Render reads the blueprint in this repo and builds **four pieces** for you: the MCP server (the door your agent knocks on), a search memory, an embedding engine, and the operator-deck backend. The search memory and embedding engine are kept **private** — only your own server can reach them.
189+
3. In a few minutes the MCP server is live at a URL like `https://ats-mcp.onrender.com`. Its tasks-by-meaning search works out of the box; you only add your own task-system token when you want it reading *your* tasks.
190+
4. **Connect your agent — safely.** Open the **ats-mcp** service's **Environment** tab in Render and copy the auto-generated **`ATS_MCP_TOKEN`**. That token is the lock on the door: every request must carry it. Point your MCP client at `https://<your-mcp-url>/mcp` and have it send the header `Authorization: Bearer <ATS_MCP_TOKEN>`. No token, no access.
191+
5. **Wire your own tasks (optional).** In the same tab, paste your task-system token into `TICKTICK_ACCESS_TOKEN`. The server restarts and now reads your real tasks.
191192

192193
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/renezander030/agentic-task-system)
193194

194-
> Render's free tier sleeps after inactivity, so the first request after a quiet spell takes ~30s to wake. Any cheap VPS — or a paid Render instance — keeps it always on.
195+
> This stack runs on Render's paid instances (the search memory and embedding engine each need a little always-on RAM and a small disk), so it stays awake and answers instantly — no cold-start wait. Prefer to run it on your own machine or a VPS instead? The same pieces are plain Docker containers; see [the deploy guide](deploy/README.md).
195196
196197
## Architecture
197198

deploy/README.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Deploying the ATS backend
2+
3+
The [`render.yaml`](../render.yaml) blueprint at the repo root deploys the whole
4+
ATS backend with one click (see the [Deploy it yourself](../README.md#deploy-it-yourself)
5+
section of the main README). This guide explains what gets created, how the
6+
security works, how to connect your agent, and how to run the same pieces on
7+
your own machine instead.
8+
9+
## What the blueprint creates
10+
11+
| Service | Type | Public? | What it is |
12+
| --- | --- | --- | --- |
13+
| `ats-qdrant` | private | no | **Search memory** — a [Qdrant](https://qdrant.tech) vector database holding task embeddings. |
14+
| `ats-ollama` | private | no | **Embedding engine**[Ollama](https://ollama.com) serving `nomic-embed-text` (768-dim). Turns text into vectors for the search memory. |
15+
| `ats-mcp` | web | **yes, token-gated** | **MCP server** — the door your AI agent connects to. Speaks MCP over HTTP + SSE. |
16+
| `ats-operator-backend` | web | yes | **Operator deck backend** — the swipe-to-approve card UI's backend. Demo mode by default. |
17+
18+
The search memory and embedding engine are **private services**: they have no
19+
public address and can only be reached by your other services over Render's
20+
internal network. The only thing exposed to the internet is the MCP server, and
21+
that is locked behind a bearer token.
22+
23+
```
24+
your AI agent
25+
│ Authorization: Bearer <ATS_MCP_TOKEN>
26+
27+
ats-mcp (public) ──► auth gateway ──► mcp-proxy ──► ATS stdio server
28+
│ │
29+
│ private network │
30+
├────────────────────────────► ats-qdrant (private, vectors)
31+
└────────────────────────────► ats-ollama (private, embeddings)
32+
```
33+
34+
## How "safely" works
35+
36+
A Render web service exposes exactly one public port. For `ats-mcp` that port is
37+
a small **bearer-token gateway** ([`deploy/mcp/auth-gateway.mjs`](mcp/auth-gateway.mjs)):
38+
39+
- Every request must carry `Authorization: Bearer <ATS_MCP_TOKEN>`. Without it
40+
the gateway returns `401` and never touches the MCP server.
41+
- `ATS_MCP_TOKEN` is **auto-generated** by Render (`generateValue: true`), so each
42+
deploy gets a strong, unique secret. You read it from the dashboard; it is
43+
never committed to the repo.
44+
- The only unauthenticated route is `GET /healthz`, which returns a static `ok`
45+
and proxies nothing.
46+
- Inside the container, `mcp-proxy` bridges the stdio MCP server to HTTP (`/mcp`,
47+
streamable) and SSE (`/sse`) on localhost only — it is never publicly bound.
48+
49+
## Connect your agent
50+
51+
Point your MCP client at `https://<your-mcp-url>/mcp` and send the bearer token.
52+
53+
**Claude Code / Desktop** (`~/.claude.json` or the desktop config), as a remote
54+
MCP server:
55+
56+
```jsonc
57+
{
58+
"mcpServers": {
59+
"ats": {
60+
"type": "http",
61+
"url": "https://ats-mcp.onrender.com/mcp",
62+
"headers": { "Authorization": "Bearer YOUR_ATS_MCP_TOKEN" }
63+
}
64+
}
65+
}
66+
```
67+
68+
Quick liveness check (no token needed):
69+
70+
```bash
71+
curl https://ats-mcp.onrender.com/healthz # -> {"ok":true,"service":"ats-mcp"}
72+
```
73+
74+
## Environment variables (`ats-mcp`)
75+
76+
| Variable | Set by | Purpose |
77+
| --- | --- | --- |
78+
| `ATS_MCP_TOKEN` | Render (generated) | The bearer token your agent must send. |
79+
| `ATS_ADAPTER` | blueprint | Which task system to expose (default `@reneza/ats-adapter-ticktick`). |
80+
| `QDRANT_HOST` / `OLLAMA_HOST` | `fromService` | Private hostnames of the two backend services; the entrypoint composes `QDRANT_URL` / `OLLAMA_URL` from them. |
81+
| `TICKTICK_ACCESS_TOKEN` | you (optional) | Your task-system token. Paste it to make the server read your real tasks. |
82+
| `TICKTICK_CLIENT_ID` / `TICKTICK_CLIENT_SECRET` | you (optional) | Only needed if you want the token to auto-refresh. A long-lived Open API token does not. |
83+
84+
Without a token the server still boots and `tools/list` works; tools that need
85+
credentials simply error until you add one.
86+
87+
## Run it on your own machine instead
88+
89+
The services are plain containers — the same images, no Render required:
90+
91+
```bash
92+
# 1) Search memory + embedding engine
93+
docker run -d --name qdrant -p 6333:6333 -v qdrant_storage:/qdrant/storage qdrant/qdrant
94+
docker run -d --name ollama -p 11434:11434 -v ollama_models:/root/.ollama ollama/ollama
95+
docker exec ollama ollama pull nomic-embed-text
96+
97+
# 2) MCP server (built from this repo's deploy/mcp/Dockerfile)
98+
docker build -f deploy/mcp/Dockerfile -t ats-mcp .
99+
docker run -d --name ats-mcp -p 8443:10000 \
100+
-e PORT=10000 \
101+
-e ATS_MCP_TOKEN="pick-a-long-secret" \
102+
-e QDRANT_HOST=host.docker.internal \
103+
-e OLLAMA_HOST=host.docker.internal \
104+
-e TICKTICK_ACCESS_TOKEN="your-token" \
105+
ats-mcp
106+
```
107+
108+
Your agent then connects to `http://localhost:8443/mcp` with the same bearer
109+
token. For a no-container dev loop, the MCP server also runs straight over stdio
110+
(`npx @reneza/ats-mcp`) — see [`packages/mcp`](../packages/mcp/).

deploy/mcp/Dockerfile

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# ATS MCP server for Render — a public, bearer-token-gated HTTP/SSE endpoint.
2+
#
3+
# Builds the workspace from source (plain JS, no build step), installs mcp-proxy
4+
# to bridge the stdio MCP server to HTTP, and ships a tiny bearer-auth gateway
5+
# (deploy/mcp/auth-gateway.mjs) as the only public port. Build context is the
6+
# repo root (dockerContext: . in render.yaml).
7+
FROM node:22-alpine
8+
9+
# mcp-proxy 6.5.1 — the version proven in the Glama deployment (3.0.3 crashes on
10+
# completions). It exposes /mcp (streamable HTTP) and /sse from a stdio server.
11+
RUN npm install -g mcp-proxy@6.5.1
12+
13+
WORKDIR /app
14+
15+
# Install workspace deps from source. --ignore-scripts skips the husky prepare
16+
# hook (no git in the build context).
17+
COPY package.json package-lock.json ./
18+
COPY packages ./packages
19+
RUN npm ci --ignore-scripts
20+
21+
# Deploy entrypoint + gateway.
22+
COPY deploy/mcp ./deploy/mcp
23+
RUN chmod +x /app/deploy/mcp/start.sh
24+
25+
# Default adapter (a workspace package). Override at runtime with ATS_ADAPTER.
26+
ENV ATS_ADAPTER=@reneza/ats-adapter-ticktick
27+
# Internal port mcp-proxy listens on; the gateway proxies to it and binds $PORT.
28+
ENV MCP_PROXY_PORT=8080
29+
30+
CMD ["/bin/sh", "/app/deploy/mcp/start.sh"]

deploy/mcp/auth-gateway.mjs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Bearer-token auth gateway for the ATS MCP server on Render.
4+
*
5+
* A Render web service exposes exactly ONE public port. This gateway is that
6+
* port: it checks `Authorization: Bearer <ATS_MCP_TOKEN>` on every request,
7+
* then streams the request through to mcp-proxy on 127.0.0.1:<MCP_PROXY_PORT>
8+
* (which speaks the MCP Streamable HTTP `/mcp` and SSE `/sse` transports over
9+
* the stdio server). No token, no entry — so the user's agent can reach the
10+
* MCP server safely over the public internet.
11+
*
12+
* Unauthenticated exception: GET /healthz (Render's health check + a liveness
13+
* probe anyone can hit). It returns only a static ok and never proxies.
14+
*
15+
* Streaming matters: SSE responses must flow byte-for-byte without buffering,
16+
* and the connection is long-lived — so we pipe raw and disable the HTTP
17+
* server timeouts that would otherwise sever an idle stream.
18+
*/
19+
import http from 'node:http';
20+
21+
const PORT = Number(process.env.PORT) || 10000;
22+
const UPSTREAM_HOST = '127.0.0.1';
23+
const UPSTREAM_PORT = Number(process.env.MCP_PROXY_PORT) || 8080;
24+
const TOKEN = process.env.ATS_MCP_TOKEN || '';
25+
26+
if (!TOKEN) {
27+
console.error(
28+
'[ats-mcp-gateway] FATAL: ATS_MCP_TOKEN is not set — refusing to start an unauthenticated MCP endpoint.'
29+
);
30+
process.exit(1);
31+
}
32+
33+
function unauthorized(res) {
34+
res.writeHead(401, {
35+
'content-type': 'application/json',
36+
'www-authenticate': 'Bearer realm="ats-mcp"',
37+
});
38+
res.end(JSON.stringify({ error: 'unauthorized', detail: 'Send Authorization: Bearer <token>' }));
39+
}
40+
41+
const server = http.createServer((req, res) => {
42+
// Liveness probe — no token required, proxies nothing, reveals nothing.
43+
if (req.method === 'GET' && req.url === '/healthz') {
44+
res.writeHead(200, { 'content-type': 'application/json' });
45+
res.end(JSON.stringify({ ok: true, service: 'ats-mcp' }));
46+
return;
47+
}
48+
49+
const auth = req.headers['authorization'] || '';
50+
const expected = `Bearer ${TOKEN}`;
51+
// Length check first so the constant string compare doesn't leak via early exit.
52+
if (auth.length !== expected.length || auth !== expected) {
53+
unauthorized(res);
54+
return;
55+
}
56+
57+
// Pass the request straight through to mcp-proxy. Piping (no buffering) is
58+
// what keeps SSE working.
59+
const upstream = http.request(
60+
{
61+
host: UPSTREAM_HOST,
62+
port: UPSTREAM_PORT,
63+
method: req.method,
64+
path: req.url,
65+
headers: { ...req.headers, host: `${UPSTREAM_HOST}:${UPSTREAM_PORT}` },
66+
},
67+
(upRes) => {
68+
res.writeHead(upRes.statusCode || 502, upRes.headers);
69+
upRes.pipe(res);
70+
}
71+
);
72+
upstream.on('error', (err) => {
73+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json' });
74+
res.end(JSON.stringify({ error: 'bad_gateway', detail: String((err && err.message) || err) }));
75+
});
76+
req.pipe(upstream);
77+
});
78+
79+
// SSE connections are long-lived — disable the timeouts that would sever them.
80+
server.requestTimeout = 0;
81+
server.headersTimeout = 0;
82+
server.timeout = 0;
83+
84+
server.listen(PORT, () => {
85+
console.error(
86+
`[ats-mcp-gateway] listening on :${PORT} -> mcp-proxy ${UPSTREAM_HOST}:${UPSTREAM_PORT} (bearer-gated)`
87+
);
88+
});

deploy/mcp/start.sh

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/bin/sh
2+
# Entrypoint for the ATS MCP web service on Render.
3+
#
4+
# 1. Materialize TickTick credentials from env (Render has no ~/.config).
5+
# 2. Compose QDRANT_URL / OLLAMA_URL from the private hostnames Render injects.
6+
# 3. Launch the stdio MCP server behind mcp-proxy (HTTP + SSE), then the
7+
# bearer-token gateway that is the only public port.
8+
set -e
9+
10+
# --- 1. Credentials ---------------------------------------------------------
11+
# The ticktick adapter reads ~/.config/ats/{tokens.json,config.json}. If the
12+
# operator pasted a token into the Render dashboard, write those files so the
13+
# adapter authenticates without an interactive OAuth flow. With no token set,
14+
# the server still boots and `tools/list` works — tools that need credentials
15+
# simply error until a token is added.
16+
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/ats"
17+
if [ -n "$TICKTICK_ACCESS_TOKEN" ]; then
18+
mkdir -p "$CONFIG_DIR"
19+
# expiresAt is set far in the future (year 2100) so the adapter never tries to
20+
# refresh — a long-lived TickTick Open API token does not expire on its own.
21+
cat > "$CONFIG_DIR/tokens.json" <<EOF
22+
{"accessToken":"$TICKTICK_ACCESS_TOKEN","refreshToken":"${TICKTICK_REFRESH_TOKEN:-}","expiresAt":4102444800000,"tokenType":"bearer"}
23+
EOF
24+
chmod 600 "$CONFIG_DIR/tokens.json"
25+
cat > "$CONFIG_DIR/config.json" <<EOF
26+
{"clientId":"${TICKTICK_CLIENT_ID:-unused}","clientSecret":"${TICKTICK_CLIENT_SECRET:-unused}","region":"${TICKTICK_REGION:-global}"}
27+
EOF
28+
chmod 600 "$CONFIG_DIR/config.json"
29+
echo "[ats-mcp] wrote TickTick credentials to $CONFIG_DIR"
30+
fi
31+
32+
# --- 2. Service URLs --------------------------------------------------------
33+
# QDRANT_HOST / OLLAMA_HOST come from fromService (host only); ports are fixed.
34+
if [ -n "$QDRANT_HOST" ]; then export QDRANT_URL="http://$QDRANT_HOST:6333"; fi
35+
if [ -n "$OLLAMA_HOST" ]; then export OLLAMA_URL="http://$OLLAMA_HOST:11434"; fi
36+
echo "[ats-mcp] QDRANT_URL=${QDRANT_URL:-unset} OLLAMA_URL=${OLLAMA_URL:-unset} adapter=${ATS_ADAPTER:-default}"
37+
38+
# --- 3. Processes -----------------------------------------------------------
39+
MCP_PROXY_PORT="${MCP_PROXY_PORT:-8080}"
40+
export MCP_PROXY_PORT
41+
42+
# mcp-proxy bridges our stdio MCP server to HTTP (/mcp) + SSE (/sse) on localhost.
43+
mcp-proxy --port "$MCP_PROXY_PORT" -- node /app/packages/mcp/server.js &
44+
PROXY_PID=$!
45+
46+
# The gateway is the public port; it bearer-gates every request to mcp-proxy.
47+
node /app/deploy/mcp/auth-gateway.mjs &
48+
GATE_PID=$!
49+
50+
# If either process exits, bring the container down so Render restarts it clean.
51+
while kill -0 "$PROXY_PID" 2>/dev/null && kill -0 "$GATE_PID" 2>/dev/null; do
52+
sleep 5
53+
done
54+
echo "[ats-mcp] a child process exited — shutting down for a clean restart"
55+
kill "$PROXY_PID" "$GATE_PID" 2>/dev/null || true
56+
exit 1

deploy/ollama/Dockerfile

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# ATS embedding engine for Render — Ollama serving nomic-embed-text (768-dim),
2+
# the same model the ticktick adapter uses everywhere else. Private service:
3+
# only ats-mcp (and other backend services) reach it over Render's network.
4+
# Build context is the repo root (dockerContext: . in render.yaml).
5+
FROM ollama/ollama:latest
6+
7+
ENV OLLAMA_HOST=0.0.0.0:11434
8+
9+
COPY deploy/ollama/start.sh /start.sh
10+
RUN chmod +x /start.sh
11+
12+
# The base image sets ENTRYPOINT ["/bin/ollama"]; clear it so our script runs.
13+
ENTRYPOINT []
14+
CMD ["/bin/sh", "/start.sh"]

deploy/ollama/start.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/bin/sh
2+
# Entrypoint for the ATS embedding engine on Render.
3+
#
4+
# Starts Ollama, waits for it to accept connections, then pulls the embedding
5+
# model. The pull is idempotent and the model lives on a mounted disk
6+
# (/root/.ollama), so a restart reuses it instead of downloading again.
7+
set -e
8+
9+
export OLLAMA_HOST="0.0.0.0:11434"
10+
MODEL="${EMBEDDING_MODEL:-nomic-embed-text}"
11+
12+
ollama serve &
13+
SERVE_PID=$!
14+
15+
echo "[ats-ollama] waiting for ollama to come up..."
16+
until ollama list >/dev/null 2>&1; do
17+
sleep 1
18+
done
19+
20+
echo "[ats-ollama] ensuring embedding model: $MODEL"
21+
ollama pull "$MODEL"
22+
echo "[ats-ollama] ready on :11434 (model: $MODEL)"
23+
24+
wait "$SERVE_PID"

examples/operator-deck/demo-data.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ export const DEMO_SUGGESTIONS = [
4747
back: { heading: 'Why', body: 'No movement for 47 days and the queue decision already shipped. Archiving (reversible) stops dead context steering planning.' },
4848
},
4949
{
50-
id: 'demo-merge-fde', kind: 'relate', score: 0.61,
50+
id: 'demo-merge-roles', kind: 'relate', score: 0.61,
5151
items: [
52-
{ adapter: 'ticktick', title: 'FDE-044: AI Engineer (Python + React)' },
53-
{ adapter: 'ticktick', title: 'FDE-055: FullStack AI Developer' },
52+
{ adapter: 'ticktick', title: 'JOB-201: Senior Backend Engineer (Go)' },
53+
{ adapter: 'ticktick', title: 'JOB-214: Backend Engineer, Platform team' },
5454
],
5555
action: 'Merge these duplicate role specs',
5656
back: { heading: 'Why', body: 'Near-duplicate role specs sitting apart. Linking them as the same search keeps them from drifting out of sync.' },

0 commit comments

Comments
 (0)