@cloudgrid-io/mcp 0.11.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/package.json +2 -1
  2. package/src/corpus/capability-map.md +40 -6
  3. package/src/corpus/examples/ai-app/index.md +237 -0
  4. package/src/corpus/examples/api-service/index.md +190 -0
  5. package/src/corpus/examples/crm/index.md +273 -0
  6. package/src/corpus/examples/kanban/index.md +256 -0
  7. package/src/corpus/templates/admin-dashboard/README.md +75 -0
  8. package/src/corpus/templates/admin-dashboard/cloudgrid.yaml +15 -0
  9. package/src/corpus/templates/admin-dashboard/index.md +78 -0
  10. package/src/corpus/templates/admin-dashboard/services/web/app/api/orders/route.js +64 -0
  11. package/src/corpus/templates/admin-dashboard/services/web/app/api/users/route.js +63 -0
  12. package/src/corpus/templates/admin-dashboard/services/web/app/layout.js +39 -0
  13. package/src/corpus/templates/admin-dashboard/services/web/app/page.js +73 -0
  14. package/src/corpus/templates/admin-dashboard/services/web/app/resource-table.js +94 -0
  15. package/src/corpus/templates/admin-dashboard/services/web/lib/db.js +32 -0
  16. package/src/corpus/templates/admin-dashboard/services/web/package.json +16 -0
  17. package/src/corpus/templates/ai-app/README.md +95 -0
  18. package/src/corpus/templates/ai-app/cloudgrid.yaml +20 -0
  19. package/src/corpus/templates/ai-app/index.md +269 -0
  20. package/src/corpus/templates/ai-app/services/web/app/api/chat/route.js +51 -0
  21. package/src/corpus/templates/ai-app/services/web/app/chat.js +60 -0
  22. package/src/corpus/templates/ai-app/services/web/app/layout.js +36 -0
  23. package/src/corpus/templates/ai-app/services/web/app/page.js +25 -0
  24. package/src/corpus/templates/ai-app/services/web/lib/db.js +32 -0
  25. package/src/corpus/templates/ai-app/services/web/package.json +17 -0
  26. package/src/corpus/templates/api-docs/README.md +28 -0
  27. package/src/corpus/templates/api-docs/cloudgrid.yaml +10 -0
  28. package/src/corpus/templates/api-docs/index.html +136 -0
  29. package/src/corpus/templates/api-docs/index.md +30 -0
  30. package/src/corpus/templates/api-service/README.md +87 -0
  31. package/src/corpus/templates/api-service/cloudgrid.yaml +18 -0
  32. package/src/corpus/templates/api-service/index.md +225 -0
  33. package/src/corpus/templates/api-service/services/api/package.json +13 -0
  34. package/src/corpus/templates/api-service/services/api/src/index.js +132 -0
  35. package/src/corpus/templates/changelog/README.md +27 -0
  36. package/src/corpus/templates/changelog/cloudgrid.yaml +10 -0
  37. package/src/corpus/templates/changelog/index.html +80 -0
  38. package/src/corpus/templates/changelog/index.md +30 -0
  39. package/src/corpus/templates/crm/README.md +72 -0
  40. package/src/corpus/templates/crm/cloudgrid.yaml +15 -0
  41. package/src/corpus/templates/crm/index.md +68 -0
  42. package/src/corpus/templates/crm/services/web/app/api/contacts/route.js +80 -0
  43. package/src/corpus/templates/crm/services/web/app/contact-manager.js +102 -0
  44. package/src/corpus/templates/crm/services/web/app/layout.js +35 -0
  45. package/src/corpus/templates/crm/services/web/app/page.js +34 -0
  46. package/src/corpus/templates/crm/services/web/lib/db.js +32 -0
  47. package/src/corpus/templates/crm/services/web/package.json +16 -0
  48. package/src/corpus/templates/docs-site/README.md +28 -0
  49. package/src/corpus/templates/docs-site/cloudgrid.yaml +10 -0
  50. package/src/corpus/templates/docs-site/index.html +122 -0
  51. package/src/corpus/templates/docs-site/index.md +30 -0
  52. package/src/corpus/templates/kanban/README.md +72 -0
  53. package/src/corpus/templates/kanban/cloudgrid.yaml +15 -0
  54. package/src/corpus/templates/kanban/index.md +68 -0
  55. package/src/corpus/templates/kanban/services/web/app/api/cards/route.js +70 -0
  56. package/src/corpus/templates/kanban/services/web/app/board.js +93 -0
  57. package/src/corpus/templates/kanban/services/web/app/layout.js +37 -0
  58. package/src/corpus/templates/kanban/services/web/app/page.js +32 -0
  59. package/src/corpus/templates/kanban/services/web/lib/db.js +32 -0
  60. package/src/corpus/templates/kanban/services/web/package.json +16 -0
  61. package/src/corpus/templates/portfolio/README.md +29 -0
  62. package/src/corpus/templates/portfolio/cloudgrid.yaml +10 -0
  63. package/src/corpus/templates/portfolio/index.html +126 -0
  64. package/src/corpus/templates/portfolio/index.md +30 -0
  65. package/src/corpus/templates/saas-marketing/README.md +30 -0
  66. package/src/corpus/templates/saas-marketing/cloudgrid.yaml +10 -0
  67. package/src/corpus/templates/saas-marketing/index.html +212 -0
  68. package/src/corpus/templates/saas-marketing/index.md +30 -0
  69. package/src/corpus/templates/status-page/README.md +28 -0
  70. package/src/corpus/templates/status-page/cloudgrid.yaml +10 -0
  71. package/src/corpus/templates/status-page/index.html +117 -0
  72. package/src/corpus/templates/status-page/index.md +30 -0
  73. package/src/corpus/templates/task-manager/README.md +72 -0
  74. package/src/corpus/templates/task-manager/cloudgrid.yaml +15 -0
  75. package/src/corpus/templates/task-manager/index.md +68 -0
  76. package/src/corpus/templates/task-manager/services/web/app/api/tasks/route.js +77 -0
  77. package/src/corpus/templates/task-manager/services/web/app/layout.js +39 -0
  78. package/src/corpus/templates/task-manager/services/web/app/page.js +33 -0
  79. package/src/corpus/templates/task-manager/services/web/app/task-list.js +110 -0
  80. package/src/corpus/templates/task-manager/services/web/lib/db.js +32 -0
  81. package/src/corpus/templates/task-manager/services/web/package.json +16 -0
  82. package/src/corpus/templates/waitlist/README.md +38 -0
  83. package/src/corpus/templates/waitlist/cloudgrid.yaml +10 -0
  84. package/src/corpus/templates/waitlist/index.html +69 -0
  85. package/src/corpus/templates/waitlist/index.md +41 -0
  86. package/src/corpus/workflows/admin-dashboard.md +102 -0
  87. package/src/corpus/workflows/ai-app.md +136 -0
  88. package/src/corpus/workflows/api-docs.md +36 -0
  89. package/src/corpus/workflows/api-service.md +118 -0
  90. package/src/corpus/workflows/changelog.md +33 -0
  91. package/src/corpus/workflows/crm.md +101 -0
  92. package/src/corpus/workflows/docs-site.md +35 -0
  93. package/src/corpus/workflows/kanban.md +102 -0
  94. package/src/corpus/workflows/portfolio.md +35 -0
  95. package/src/corpus/workflows/saas-marketing.md +37 -0
  96. package/src/corpus/workflows/status-page.md +35 -0
  97. package/src/corpus/workflows/task-manager.md +101 -0
  98. package/src/corpus/workflows/waitlist.md +42 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.11.2",
3
+ "version": "0.13.0",
4
4
  "description": "MCP server for CloudGrid. Three editions: a local stdio server (full toolset), a hosted web server (light, CLI-free toolset), and a public docs server (search_cloudgrid_documentation) over MCP Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,7 @@
20
20
  "test:error-reporting": "node test/error-reporting.test.mjs",
21
21
  "test:source": "node test/source-fetch.test.mjs",
22
22
  "test:app-with-data": "node test/app-with-data.test.mjs",
23
+ "test:archetypes": "node test/archetypes.test.mjs",
23
24
  "test:self-describing": "node test/self-describing.test.mjs",
24
25
  "test:canonical-yaml": "node test/canonical-yaml.test.mjs",
25
26
  "test:content": "node test/content-handling.test.mjs",
@@ -13,20 +13,53 @@ cloudgrid.yaml schema (every field, the `needs:` injection table, the
13
13
  requires-vs-needs caveat, validation rules), fetch the companion reference:
14
14
  `gridctl_fetch("doc", "cloudgrid-yaml")`.
15
15
 
16
- ## The 6 templates
16
+ ## The 19 templates
17
17
 
18
18
  | Intent (match on `when:`) | Template | `needs:` | Deploy | Edition |
19
19
  |---|---|---|---|---|
20
- | landing page, marketing/product/hero page, coming-soon, waitlist, pricing, portfolio, link-in-bio, event page | `landing-page` | none | inspiration (instant) | all |
20
+ | landing page, marketing/product/hero page, coming-soon, link-in-bio, event page — a single-section page | `landing-page` | none | inspiration (instant) | all |
21
+ | SaaS marketing site, product marketing page, features + pricing page — multi-section with pricing tiers, testimonials, FAQ | `saas-marketing` | none | inspiration (instant) | all |
22
+ | documentation site, docs, developer docs, guide, manual, knowledge base — prose docs with sidebar + search | `docs-site` | none | inspiration (instant) | all |
23
+ | API documentation, API reference, endpoint docs, REST API docs — method badges, params tables, request/response examples | `api-docs` | none | inspiration (instant) | all |
24
+ | status page, service status, uptime page, incident history, is-it-down page — display-only, status baked in | `status-page` | none | inspiration (instant) | all |
25
+ | changelog, release notes, what's new, product updates, version history | `changelog` | none | inspiration (instant) | all |
26
+ | portfolio, personal site, freelancer site, my work, showcase — projects grid + about + skills + contact | `portfolio` | none | inspiration (instant) | all |
27
+ | waitlist, coming soon, early access, join the list, launch page — email capture form (form is static; storing signups needs a runtime crud-app) | `waitlist` | none | inspiration (instant) | all |
21
28
  | calculator, converter, generator, timer, quiz, interactive tool, mini-app, widget — computed client-side, no saved data | `web-app` | none | inspiration (instant) | all |
22
29
  | dashboard, metrics, KPIs, stats page, status board, charts, analytics view — display-only, static data baked in | `dashboard` | none | inspiration (instant) | all |
23
30
  | report, one-pager, summary, brief, whitepaper, case study, formatted document | `report` | none | inspiration (instant) | all |
24
31
  | slides, deck, pitch, presentation, slideshow | `presentation` (dir: `deck`) | none | inspiration (instant) | all |
25
32
  | to-do, task list, notes app, guestbook, CRUD app, a form that SAVES/STORES submissions, anything that PERSISTS data or shares state across users/sessions, sign-in/accounts | `app-with-data` | `database: true` | runtime (async, poll) | local |
33
+ | CRM, contacts, leads, sales pipeline, customer manager, deal tracker | `crm` | `database: true` | runtime (async, poll) | local |
34
+ | kanban board, task board, trello-style board, workflow columns | `kanban` | `database: true` | runtime (async, poll) | local |
35
+ | task manager, to-do app (richer than the app-with-data starter), task list with due dates/priorities | `task-manager` | `database: true` | runtime (async, poll) | local |
36
+ | admin dashboard, admin panel, back-office, CRUD admin, manage records | `admin-dashboard` | `database: true` | runtime (async, poll) | local |
37
+ | REST API, backend for X, API endpoint(s), CRUD API, JSON API, webhook receiver — a backend/service that stores data and isn't a full web UI | `api-service` | `database: true` | runtime (async, poll) | local |
38
+ | chatbot, AI assistant, Q&A bot, conversational app, support bot, ask-me-anything, an app that talks to an LLM / generates text with AI | `ai-app` | `ai: true, database: true` | runtime (async, poll) | local |
26
39
 
27
40
  **Rule of thumb:** if the app must SAVE/remember data, share state across
28
- users/sessions, log in, or store submissions → it is persistent → `app-with-data`
29
- (runtime, local edition). Otherwise a static template deploys instantly anywhere.
41
+ users/sessions, log in, or store submissions → it is persistent → runtime, local
42
+ edition. Pick by shape: a full web UI → `app-with-data`; a plain backend/JSON API
43
+ (no UI) → `api-service`; an app that talks to an LLM → `ai-app` (adds `ai:`).
44
+ Otherwise a static template deploys instantly anywhere.
45
+
46
+ **The DB-CRUD family.** `crm`, `kanban`, `task-manager`, and `admin-dashboard`
47
+ all share the exact `app-with-data` shape (nextjs + `needs: { database: true }`,
48
+ app under `services/web/`, lazy Mongo getter, force-dynamic App-Router routes) —
49
+ they differ only by domain schema + UI. Match the request to the closest one by
50
+ its `when:`; if it is a bare to-do or generic "save this data" with no specific
51
+ domain, use `app-with-data` (the minimal reference). Deploy path is identical for
52
+ all five.
53
+
54
+ ## Held / pending platform (not yet buildable)
55
+
56
+ Two more archetypes are designed but blocked on platform work — the LLM should
57
+ know they are coming but must NOT author them yet:
58
+
59
+ | Intent | Would need | Status |
60
+ |---|---|---|
61
+ | scheduled task, cron job, "run every day/hour", periodic worker | a `type: cron` service (`schedule`, `timezone`) | **HELD** — platform issue #1543 (cron entities crash the online-check). Do not build. |
62
+ | RAG chatbot, "answer over my own docs", semantic search, retrieval-augmented Q&A | `ai-app` + `needs: { vector: pgvector }` | **HELD** — platform issue #1545 (pgvector crashes the deploy). Ship the plain `ai-app` (no vector) for now. |
30
63
 
31
64
  ## The full `needs:` vocabulary (the whole menu)
32
65
 
@@ -65,8 +98,9 @@ engine hint. Cron is NOT a need — it is a **service type** (`type: cron` with
65
98
  ## How to choose
66
99
 
67
100
  1. Read the request; match it against the workflow `when:` triggers above.
68
- 2. Adopt that template's `needs:`. Persistence → `database`. (Future: scheduled →
69
- `cron` service; semantic search / RAG → `vector` + `ai`.)
101
+ 2. Adopt that template's `needs:`. Persistence → `database` (`app-with-data` or
102
+ `api-service`); talks to an LLM → `ai: true` + `database` (`ai-app`). (Held for
103
+ now: scheduled → a `cron` service, #1543; RAG → `vector: pgvector`, #1545.)
70
104
  3. Static (`needs: none`) → publish as an inspiration with `gridctl_drop`
71
105
  (instant, any edition). Anything with a `needs:` → runtime, local edition,
72
106
  `gridctl_plug` a linked folder, then poll to a live URL.
@@ -0,0 +1,237 @@
1
+ # Example: ai-app — "Trip Planner Bot"
2
+
3
+ A filled reference imitating the `ai-app` template: a themed AI assistant — a
4
+ travel trip planner. Same stack (Next.js App Router + `@cloudgrid-io/ai` for the
5
+ grid AI gateway + the `mongodb` driver for chat history), slightly richer than
6
+ the bare chatbot: it gives the assistant a **persona** via a `system` message and
7
+ sends the **prior conversation** as context, so replies stay on-theme and
8
+ coherent across turns.
9
+
10
+ Data persists in the grid-shared Mongo, so the conversation survives refresh.
11
+
12
+ **Same four proven rules as the template:** (1) app code lives under
13
+ **`services/web/`** (the service name, NOT the repo root — `path:` is the URL
14
+ mount); (2) the AI call is **zero-config — `createClient()` with no key** (it
15
+ uses the in-grid identity); (3) the DB connection string is read **lazily inside
16
+ the `getDb` getter**, never at module top level (a top-level read fails `next
17
+ build`); (4) the datastore is declared with the canonical **`needs: { ai: true,
18
+ database: true }`**. Don't use the deprecated `requires:` alias.
19
+
20
+ ## cloudgrid.yaml
21
+
22
+ ```yaml
23
+ # `needs: { ai: true, database: true }` is canonical: the deployer wires the AI
24
+ # gateway (in-grid identity — no key) and provisions Mongo, injecting
25
+ # DATABASE_MONGODB_URL (plus the legacy MONGODB_URL alias). `requires:` is the
26
+ # deprecated v1 alias — don't mix the two (the validator rejects it).
27
+ name: trip-planner-bot
28
+ services:
29
+ web:
30
+ type: nextjs
31
+ path: /
32
+ needs:
33
+ ai: true
34
+ database: true
35
+ # RAG over your own docs needs `needs: { vector: pgvector }` — not yet available
36
+ # (platform issue #1545).
37
+ ```
38
+
39
+ ## services/web/package.json
40
+
41
+ ```json
42
+ {
43
+ "name": "trip-planner-bot",
44
+ "version": "0.1.0",
45
+ "private": true,
46
+ "scripts": { "dev": "next dev", "build": "next build", "start": "next start" },
47
+ "dependencies": {
48
+ "next": "^15.1.0",
49
+ "react": "^19.0.0",
50
+ "react-dom": "^19.0.0",
51
+ "@cloudgrid-io/ai": "^0.2.0",
52
+ "mongodb": "^6.12.0"
53
+ }
54
+ }
55
+ ```
56
+
57
+ ## services/web/lib/db.js
58
+
59
+ ```js
60
+ // Cached Mongo client for the chat history. The grid injects DATABASE_MONGODB_URL
61
+ // (plus the legacy MONGODB_URL alias) at dev + runtime. Resolve it LAZILY inside
62
+ // the getter — a top-level read fails `next build`. Never hardcode a connection
63
+ // string; never commit a secret.
64
+ import { MongoClient } from "mongodb";
65
+
66
+ function clientPromise() {
67
+ const uri = process.env.DATABASE_MONGODB_URL || process.env.MONGODB_URL;
68
+ if (!uri) {
69
+ throw new Error(
70
+ "DATABASE_MONGODB_URL is not set. The grid injects it — run `grid dev` " +
71
+ "locally or deploy with `grid plug`. Do not set it by hand.",
72
+ );
73
+ }
74
+ if (!globalThis.__mongoClientPromise) {
75
+ globalThis.__mongoClientPromise = new MongoClient(uri).connect();
76
+ }
77
+ return globalThis.__mongoClientPromise;
78
+ }
79
+
80
+ export async function getDb() {
81
+ return (await clientPromise()).db();
82
+ }
83
+ ```
84
+
85
+ ## services/web/app/api/chat/route.js
86
+
87
+ ```js
88
+ // Trip-planner chat route: give the assistant a persona, send the prior
89
+ // conversation as context, ask the grid AI gateway, persist the exchange,
90
+ // return the reply. Zero-config AI — createClient() with no key uses the in-grid
91
+ // identity.
92
+ import { NextResponse } from "next/server";
93
+ import { createClient } from "@cloudgrid-io/ai";
94
+ import { getDb } from "../../../lib/db.js";
95
+
96
+ export const dynamic = "force-dynamic";
97
+
98
+ const SYSTEM = {
99
+ role: "system",
100
+ content:
101
+ "You are Trip Planner Bot, a concise, friendly travel assistant. Suggest " +
102
+ "itineraries, budgets, and practical tips. Ask a clarifying question when the " +
103
+ "destination or dates are unclear.",
104
+ };
105
+
106
+ async function messages() {
107
+ return (await getDb()).collection("messages");
108
+ }
109
+
110
+ export async function POST(request) {
111
+ const body = await request.json().catch(() => ({}));
112
+ const text = typeof body.message === "string" ? body.message.trim() : "";
113
+ if (!text) return NextResponse.json({ error: "message is required" }, { status: 400 });
114
+
115
+ const col = await messages();
116
+
117
+ // Load recent history for context (last 20 turns), oldest first.
118
+ const prior = await col.find({}).sort({ createdAt: -1 }).limit(20).toArray();
119
+ prior.reverse();
120
+ const context = prior.map((m) => ({ role: m.role, content: m.content }));
121
+
122
+ // Ask the grid AI gateway: persona + prior turns + the new user message.
123
+ const client = createClient();
124
+ const r = await client.chat({
125
+ messages: [SYSTEM, ...context, { role: "user", content: text }],
126
+ });
127
+ const reply = r.text ?? r.content ?? "";
128
+
129
+ // Persist the exchange.
130
+ const now = new Date();
131
+ await col.insertMany([
132
+ { role: "user", content: text, createdAt: now },
133
+ { role: "assistant", content: reply, createdAt: new Date(now.getTime() + 1) },
134
+ ]);
135
+
136
+ return NextResponse.json({ reply });
137
+ }
138
+
139
+ export async function GET() {
140
+ const col = await messages();
141
+ const history = await col.find({}).sort({ createdAt: 1 }).toArray();
142
+ return NextResponse.json(
143
+ history.map((m) => ({ id: m._id.toString(), role: m.role, content: m.content })),
144
+ );
145
+ }
146
+ ```
147
+
148
+ ## services/web/app/page.js
149
+
150
+ ```js
151
+ import { getDb } from "../lib/db.js";
152
+ import Chat from "./chat.js";
153
+
154
+ export const dynamic = "force-dynamic";
155
+
156
+ async function listHistory() {
157
+ const items = await (await getDb())
158
+ .collection("messages")
159
+ .find({})
160
+ .sort({ createdAt: 1 })
161
+ .toArray();
162
+ return items.map((m) => ({ id: m._id.toString(), role: m.role, content: m.content }));
163
+ }
164
+
165
+ export default async function Page() {
166
+ const history = await listHistory();
167
+ return (
168
+ <main>
169
+ <h1>Trip Planner Bot</h1>
170
+ <p className="hint">Ask about a destination — powered by the grid AI gateway, saved to Mongo.</p>
171
+ <Chat initialHistory={history} />
172
+ </main>
173
+ );
174
+ }
175
+ ```
176
+
177
+ ## services/web/app/chat.js
178
+
179
+ ```js
180
+ "use client";
181
+ import { useState } from "react";
182
+
183
+ export default function Chat({ initialHistory }) {
184
+ const [history, setHistory] = useState(initialHistory);
185
+ const [text, setText] = useState("");
186
+ const [busy, setBusy] = useState(false);
187
+
188
+ async function send(e) {
189
+ e.preventDefault();
190
+ const value = text.trim();
191
+ if (!value || busy) return;
192
+ setBusy(true);
193
+ setHistory((p) => [...p, { id: `local-${Date.now()}`, role: "user", content: value }]);
194
+ setText("");
195
+ const res = await fetch("/api/chat", {
196
+ method: "POST",
197
+ headers: { "content-type": "application/json" },
198
+ body: JSON.stringify({ message: value }),
199
+ });
200
+ if (res.ok) {
201
+ const { reply } = await res.json();
202
+ setHistory((p) => [...p, { id: `local-${Date.now()}-a`, role: "assistant", content: reply }]);
203
+ }
204
+ setBusy(false);
205
+ }
206
+
207
+ return (
208
+ <div>
209
+ <ul className="log">
210
+ {history.length === 0 && <li className="empty">Where do you want to go?</li>}
211
+ {history.map((m) => (
212
+ <li key={m.id} className={m.role}>
213
+ <span className="who">{m.role === "user" ? "You" : "Trip Planner"}</span>
214
+ <span className="msg">{m.content}</span>
215
+ </li>
216
+ ))}
217
+ </ul>
218
+ <form onSubmit={send} className="row">
219
+ <input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. 4 days in Lisbon on a budget" disabled={busy} />
220
+ <button type="submit" disabled={busy}>{busy ? "…" : "Send"}</button>
221
+ </form>
222
+ </div>
223
+ );
224
+ }
225
+ ```
226
+
227
+ ## Notes
228
+
229
+ - Deploy the same way as the template: `grid dev` locally, `grid plug` to deploy
230
+ (async — poll status to a live URL). Re-plug the same entity to keep one URL.
231
+ - The needs are Mongo + the AI gateway (`needs: { ai: true, database: true }`).
232
+ The AI call is zero-config — `createClient()` with no key uses the in-grid
233
+ identity.
234
+ - All app code lives under `services/web/`; the connection is read lazily inside
235
+ `getDb`, never at module top level.
236
+ - To answer over your own documents (RAG) you'd add `needs: { vector: pgvector }`
237
+ — not yet available (platform issue #1545).
@@ -0,0 +1,190 @@
1
+ # Example: api-service — "Notes API"
2
+
3
+ A filled reference imitating the `api-service` template: a small REST/JSON
4
+ backend for notes. Same stack (a plain Node `http` server + the `mongodb`
5
+ driver, grid-shared Mongo via `process.env.DATABASE_MONGODB_URL`), slightly
6
+ richer than the bare items template — notes have a `title` and a `body`, and the
7
+ API supports fetching a single note and updating it via PUT.
8
+
9
+ Data persists in the grid-shared Mongo, so every caller sees the same notes
10
+ across sessions and refresh.
11
+
12
+ **Same four proven rules as the template:** (1) service code lives under
13
+ **`services/api/`** (the service name, NOT the repo root — `path:` is the URL
14
+ mount); (2) the DB connection string is read **lazily inside the `getClient`
15
+ getter**, never at module top level (a top-level read crashes node startup); (3)
16
+ the datastore is declared with the canonical **`needs: { database: true }`** —
17
+ the deployer provisions Mongo and injects `DATABASE_MONGODB_URL` (plus the legacy
18
+ `MONGODB_URL` alias); (4) the server listens on **`process.env.PORT || 8080`**.
19
+ Don't use the deprecated `requires:` alias.
20
+
21
+ ## cloudgrid.yaml
22
+
23
+ ```yaml
24
+ # `needs: { database: true }` is canonical: the deployer provisions Mongo and
25
+ # injects DATABASE_MONGODB_URL (plus the legacy MONGODB_URL alias). `requires:`
26
+ # is the deprecated v1 alias — don't mix the two (the validator rejects it).
27
+ # Plain Node HTTP service; code lives under services/api/.
28
+ name: notes-api
29
+ services:
30
+ api:
31
+ type: node
32
+ path: /
33
+ needs:
34
+ database: true
35
+ ```
36
+
37
+ ## services/api/package.json
38
+
39
+ ```json
40
+ {
41
+ "name": "api",
42
+ "version": "0.1.0",
43
+ "private": true,
44
+ "type": "module",
45
+ "main": "src/index.js",
46
+ "scripts": { "start": "node src/index.js" },
47
+ "dependencies": { "mongodb": "^6.12.0" }
48
+ }
49
+ ```
50
+
51
+ ## services/api/src/index.js
52
+
53
+ ```js
54
+ // Notes REST API backed by grid-shared Mongo.
55
+ // GET /notes -> list notes (newest first)
56
+ // POST /notes -> create a note; body { title, body }
57
+ // GET /notes/:id -> fetch one note
58
+ // PUT /notes/:id -> update a note; body { title?, body? }
59
+ // DELETE /notes/:id -> remove a note
60
+ //
61
+ // The grid injects DATABASE_MONGODB_URL (plus the legacy MONGODB_URL alias) at
62
+ // dev + runtime. The env var and Mongo client are resolved LAZILY inside
63
+ // getClient, never at module top level — a top-level read crashes node startup
64
+ // before the grid injects the var. Never hardcode a connection string or secret.
65
+ import { createServer } from "node:http";
66
+ import { MongoClient, ObjectId } from "mongodb";
67
+
68
+ let clientPromise;
69
+
70
+ function getClient() {
71
+ const uri = process.env.DATABASE_MONGODB_URL || process.env.MONGODB_URL;
72
+ if (!uri) {
73
+ throw new Error(
74
+ "DATABASE_MONGODB_URL is not set. The grid injects it — run `grid dev` " +
75
+ "locally or deploy with `grid plug`. Do not set it by hand.",
76
+ );
77
+ }
78
+ if (!clientPromise) clientPromise = new MongoClient(uri).connect();
79
+ return clientPromise;
80
+ }
81
+
82
+ async function notes() {
83
+ return (await getClient()).db().collection("notes");
84
+ }
85
+
86
+ function sendJson(res, status, body) {
87
+ const payload = JSON.stringify(body);
88
+ res.writeHead(status, {
89
+ "content-type": "application/json",
90
+ "content-length": Buffer.byteLength(payload),
91
+ });
92
+ res.end(payload);
93
+ }
94
+
95
+ function readBody(req) {
96
+ return new Promise((resolve) => {
97
+ let data = "";
98
+ req.on("data", (c) => {
99
+ data += c;
100
+ if (data.length > 1_000_000) req.destroy();
101
+ });
102
+ req.on("end", () => {
103
+ try {
104
+ resolve(data ? JSON.parse(data) : {});
105
+ } catch {
106
+ resolve(null);
107
+ }
108
+ });
109
+ req.on("error", () => resolve(null));
110
+ });
111
+ }
112
+
113
+ function shape(n) {
114
+ return { id: n._id.toString(), title: n.title, body: n.body, updatedAt: n.updatedAt };
115
+ }
116
+
117
+ async function handle(req, res) {
118
+ const url = new URL(req.url, "http://localhost");
119
+ const parts = url.pathname.split("/").filter(Boolean); // ["notes"] or ["notes", "<id>"]
120
+ const col = await notes();
121
+
122
+ if (parts[0] !== "notes") return sendJson(res, 404, { error: "not found" });
123
+
124
+ // Collection: /notes
125
+ if (parts.length === 1) {
126
+ if (req.method === "GET") {
127
+ const list = await col.find({}).sort({ updatedAt: -1 }).toArray();
128
+ return sendJson(res, 200, list.map(shape));
129
+ }
130
+ if (req.method === "POST") {
131
+ const body = await readBody(req);
132
+ if (body === null) return sendJson(res, 400, { error: "invalid JSON body" });
133
+ const title = typeof body.title === "string" ? body.title.trim() : "";
134
+ if (!title) return sendJson(res, 400, { error: "title is required" });
135
+ const doc = { title, body: typeof body.body === "string" ? body.body : "", updatedAt: new Date() };
136
+ const { insertedId } = await col.insertOne(doc);
137
+ return sendJson(res, 201, shape({ _id: insertedId, ...doc }));
138
+ }
139
+ return sendJson(res, 405, { error: "method not allowed" });
140
+ }
141
+
142
+ // Item: /notes/:id
143
+ const id = parts[1];
144
+ if (!ObjectId.isValid(id)) return sendJson(res, 400, { error: "invalid id" });
145
+ const _id = new ObjectId(id);
146
+
147
+ if (req.method === "GET") {
148
+ const note = await col.findOne({ _id });
149
+ if (!note) return sendJson(res, 404, { error: "note not found" });
150
+ return sendJson(res, 200, shape(note));
151
+ }
152
+ if (req.method === "PUT") {
153
+ const body = await readBody(req);
154
+ if (body === null) return sendJson(res, 400, { error: "invalid JSON body" });
155
+ const $set = { updatedAt: new Date() };
156
+ if (typeof body.title === "string") $set.title = body.title.trim();
157
+ if (typeof body.body === "string") $set.body = body.body;
158
+ const r = await col.findOneAndUpdate({ _id }, { $set }, { returnDocument: "after" });
159
+ if (!r) return sendJson(res, 404, { error: "note not found" });
160
+ return sendJson(res, 200, shape(r));
161
+ }
162
+ if (req.method === "DELETE") {
163
+ const { deletedCount } = await col.deleteOne({ _id });
164
+ if (deletedCount === 0) return sendJson(res, 404, { error: "note not found" });
165
+ return sendJson(res, 200, { ok: true });
166
+ }
167
+ return sendJson(res, 405, { error: "method not allowed" });
168
+ }
169
+
170
+ const server = createServer((req, res) => {
171
+ handle(req, res).catch((err) => {
172
+ console.error(err);
173
+ if (!res.headersSent) sendJson(res, 500, { error: "internal server error" });
174
+ });
175
+ });
176
+
177
+ const port = process.env.PORT || 8080;
178
+ server.listen(port, () => console.log(`notes-api listening on :${port}`));
179
+ ```
180
+
181
+ ## Notes
182
+
183
+ - Deploy the same way as the template: `grid dev` locally, `grid plug` to deploy
184
+ (async — poll status to a live URL). Re-plug the same entity to keep one URL.
185
+ - The only store this needs is Mongo (`needs: { database: true }` — the deployer
186
+ provisions Mongo and injects `DATABASE_MONGODB_URL`, plus the legacy
187
+ `MONGODB_URL` alias). Add more `needs:` only for infra you actually use.
188
+ - All service code lives under `services/api/`; the connection is read lazily
189
+ inside `getClient`, never at module top level; the server listens on
190
+ `process.env.PORT || 8080`.