The starter uses localStorage so it deploys without configuration. That is ideal for learning, but the data belongs to one browser profile. Cloudflare D1 can make the same todos durable and available across devices.
The upgrade has four parts: add a binding, create a table, expose CRUD routes from the existing Worker, and swap the local action implementation for fetch calls. The WebMCP tool definitions only need to await the now-asynchronous actions.
pnpm exec wrangler d1 create webmcp-todosAllow Wrangler to add the binding when prompted, use DB as its name, and keep local development local. The resulting part of wrangler.jsonc looks like:
Create migrations/0001_create_todos.sql:
CREATE TABLE todos (
id TEXT PRIMARY KEY,
text TEXT NOT NULL CHECK (length(text) BETWEEN 1 AND 200),
completed INTEGER NOT NULL DEFAULT 0 CHECK (completed IN (0, 1)),
created_at TEXT NOT NULL
);
CREATE INDEX todos_created_at ON todos (created_at);Apply it to the local and production databases, then regenerate Worker types:
pnpm exec wrangler d1 migrations apply webmcp-todos --local
pnpm exec wrangler d1 migrations apply webmcp-todos --remote
pnpm run typesType generation adds DB: D1Database to the Worker's Env interface.
Add routes under /api/todos in src/server.ts, and add "run_worker_first": ["/api/*"] to the assets object in wrangler.jsonc. D1 prepared statements keep values separate from SQL:
interface TodoRow {
id: string;
text: string;
completed: number;
created_at: string;
}
const toTodo = (row: TodoRow) => ({
id: row.id,
text: row.text,
completed: Boolean(row.completed),
createdAt: row.created_at
});
// GET /api/todos
const { results } = await env.DB.prepare(
"SELECT id, text, completed, created_at FROM todos ORDER BY created_at"
).all<TodoRow>();
return Response.json(results.map(toTodo));
// POST /api/todos with { text }
const todo = {
id: crypto.randomUUID(),
text,
completed: false,
createdAt: new Date().toISOString()
};
await env.DB.prepare(
"INSERT INTO todos (id, text, completed, created_at) VALUES (?, ?, ?, ?)"
)
.bind(todo.id, todo.text, 0, todo.createdAt)
.run();
return Response.json(todo, { status: 201 });
// PATCH /api/todos/:id with { text?, completed? }
const current = await env.DB.prepare(
"SELECT id, text, completed, created_at FROM todos WHERE id = ?"
)
.bind(id)
.first<TodoRow>();
if (!current) {
return Response.json({ error: "Todo not found" }, { status: 404 });
}
await env.DB.prepare("UPDATE todos SET text = ?, completed = ? WHERE id = ?")
.bind(
text ?? current.text,
(completed ?? Boolean(current.completed)) ? 1 : 0,
id
)
.run();
// DELETE /api/todos/:id
const result = await env.DB.prepare("DELETE FROM todos WHERE id = ?")
.bind(id)
.run();
return new Response(null, { status: result.meta.changes ? 204 : 404 });In a real application, parse each request body with the same Zod schemas before running a statement. Route matching and response handling are omitted above to keep the D1 change visible; see the D1 Workers Binding API for complete query APIs.
Keep the TodoActions interface, but make its methods asynchronous. In src/useTodos.ts, load with GET /api/todos and replace each localStorage mutation with the matching request:
const response = await fetch("/api/todos", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text })
});
const todo = await response.json();
setTodos((current) => [...current, todo]);
return todo;Finally, make each WebMCP execute callback async and await the action it already calls. The tools, generated schemas, visible UI, and agent workflow remain the same; only the persistence adapter changes.
For more detail, see D1 migrations and local D1 development.
{ "d1_databases": [ { "binding": "DB", "database_name": "webmcp-todos", "database_id": "YOUR_DATABASE_ID" } ] }