creative-graph
by JohnCarl-30
README.md
# Creative Graph
I needed one write path for a UI and an agent. The React console and the MCP server both post to `/graphql`. Neither opens SQLite on its own. Creatives are CTV, video, audio, or DOOH. Budgets are integer cents.
I made this for a developer-ecosystem interview. Bad writes fail closed. Deploy is a Dockerfile, not a slide.
## Why one schema
GraphiQL, the React console, and MCP tools all send `createCreative`, `creatives`, and `creative`. If a field is wrong, every client is wrong the same way. The MCP server does not open SQLite. It posts to `/graphql`. An agent with a raw database handle would skip the cents checks, the typed `NOT_FOUND` errors, and the SQL CHECK constraints. I did not give it that option.
## Why cents
`$1,200` is twelve hundred dollars, 120000 cents. GraphQL `Int` will not take `$1,200` or `12.5`. The console and MCP convert dollars first. The resolver still checks `Number.isInteger`. A float, extra fraction digits, or a junk string throws. A missing creative id on `updateCreative` returns `extensions.code: NOT_FOUND` and `data: null`. The field is `Creative!`, so the write cannot succeed as a silent null.
## 5-minute demo
Needs Node 22+. I checked this repo on Node 25. `node:sqlite` is built in, so there is no native addon.
```bash
cp .env.example .env
npm install
npm test
npm run dev
```
1. Open [http://127.0.0.1:5173](http://127.0.0.1:5173). If that port is already taken, Vite prints the next one. You should see the seeded Spring CTV / audio pack. A 15s CTV bumper at 50000 cents, and a 30s podcast read in DRAFT at 25000 cents.
2. Leave the budget as `$1,200`. The helper text should read `Sends budgetCents: 120000`. Click Create. A new DRAFT creative appears.
3. The console has two buttons for the failure cases, because walking someone through GraphiQL mid-interview is a bad demo. Click **Update missing ID**. The banner should show `Creative cre_does_not_exist was not found [NOT_FOUND]`.
4. Click **Send "$1,200" as GraphQL Int**. GraphQL rejects the string before a row is written.
5. Open [http://127.0.0.1:4000/graphql](http://127.0.0.1:4000/graphql) and run:
```graphql
query {
campaign(id: "cmp_demo") {
name
creatives { id name format budgetCents }
}
}
```
The schema file is [`schema.graphql`](./schema.graphql). Tables live in [`src/db/migrations/001_init.sql`](./src/db/migrations/001_init.sql).
## How to run
| What | Command | Where |
| --- | --- | --- |
| API + React | `npm run dev` | GraphQL at `:4000/graphql`. UI at `:5173`, which proxies `/graphql` |
| API only | `npm run dev:api` | `:4000` |
| Built UI served by the API | `npm run build && npm start` | UI and GraphQL both on `:4000` |
| Tests | `npm test` | schema, cents, bad-id writes, MCP input |
| MCP stdio | `npm run mcp` | talks to `GRAPHQL_URL` |
| MCP tool list | `npm run mcp:list` | prints `list_creatives`, `get_creative`, `create_creative` |
The API must be up before MCP tools do anything useful. I left it that way so MCP cannot bypass GraphQL.
## MCP in Cursor or Claude
`npm run mcp:list` should print three tools. Then add the server.
Cursor. Put this in `~/.cursor/mcp.json` or the project `.cursor/mcp.json`.
```json
{
"mcpServers": {
"creative-graph": {
"command": "npx",
"args": ["tsx", "src/mcp/index.ts"],
"cwd": "/ABS/PATH/TO/creative-graph",
"env": {
"GRAPHQL_URL": "http://127.0.0.1:4000/graphql"
}
}
}
}
```
Claude Desktop uses the same `command`, `args`, `cwd`, and `env` under `mcpServers` in `claude_desktop_config.json`.
Tool inputs are Zod-checked before the HTTP call.
- `list_creatives` optional `campaignId`, `format`
- `get_creative` requires `id`
- `create_creative` requires `campaignId`, `name`, `format`, `durationMs`, and exactly one of `budgetCents` or `budget`. `"$1,200"` is valid for `budget`.
## Deploy
This repo is not claiming a live URL. There is no Railway, Fly, or Wrangler CLI on the machine that built it.
Railway, one service, Dockerfile.
1. Push the repo to GitHub.
2. New Railway project from that repo. Builder reads `Dockerfile` / `railway.toml`.
3. Set `PORT=4000` if the platform does not inject it. `DATABASE_PATH=/app/data/creative-graph.sqlite` is already in the image.
4. Health check is `GET /health`.
5. After deploy, GraphQL is `https://<host>/graphql` and the console is `https://<host>/`.
Fly.io uses the same image. Run `fly launch` then `fly deploy`. SQLite in the container is ephemeral unless you mount a volume on `/app/data`.
No secrets are required for the demo. Copy `.env.example`. Do not commit `.env`.
## What this is not
I skipped Go and Rails. The console is one form and a table, so I did not add Redux. I used TypeScript so GraphQL Yoga and the MCP SDK live in the same repo I run on a laptop. Multi-region ad serving and frequency caps are not here. I did not model how bids get priced. Those are honest gaps against a StackAdapt Developer Ecosystem posting. What that posting actually wants is a public API as the contract. Agents use the same write path as the UI.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues