robodev 0.45.1 → 0.47.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 (45) hide show
  1. package/package.json +3 -3
  2. package/src/api.ts +13 -0
  3. package/src/collect-files.ts +7 -0
  4. package/src/config.ts +1 -0
  5. package/src/emit-starter.test.ts +69 -0
  6. package/src/index.ts +8 -0
  7. package/src/mcp-protocol.ts +120 -0
  8. package/src/mcp-tools.ts +178 -0
  9. package/src/mcp.test.ts +336 -0
  10. package/src/mcp.ts +281 -0
  11. package/templates/auth-chat/.rulesync/skills/povio-ui-styling/SKILL.md +3 -0
  12. package/templates/auth-chat/apps/fe/src/styles/base.css +0 -19
  13. package/templates/auth-chat/package.json +1 -1
  14. package/templates/backend/package.json +1 -1
  15. package/templates/catalog.json +5 -0
  16. package/templates/empty/package.json +1 -1
  17. package/templates/marketplace/.rulesync/skills/povio-ui-styling/SKILL.md +3 -0
  18. package/templates/marketplace/apps/fe/src/styles/base.css +0 -19
  19. package/templates/marketplace/package.json +1 -1
  20. package/templates/shadcn/README.md +20 -0
  21. package/templates/shadcn/ROBODEV.md +67 -0
  22. package/templates/shadcn/components.json +21 -0
  23. package/templates/shadcn/index.html +12 -0
  24. package/templates/shadcn/orval.config.ts +14 -0
  25. package/templates/shadcn/package.json +30 -0
  26. package/templates/shadcn/postcss.config.js +6 -0
  27. package/templates/shadcn/robodev/api/health.ts +7 -0
  28. package/templates/shadcn/robodev/database.ts +14 -0
  29. package/templates/shadcn/robodev/package.json +4 -0
  30. package/templates/shadcn/src/App.tsx +10 -0
  31. package/templates/shadcn/src/api/generated/endpoints.ts +54 -0
  32. package/templates/shadcn/src/api/generated/model/healthResponse.ts +11 -0
  33. package/templates/shadcn/src/api/generated/model/index.ts +9 -0
  34. package/templates/shadcn/src/api/openapi.json +42 -0
  35. package/templates/shadcn/src/app.css +117 -0
  36. package/templates/shadcn/src/components/ui/button.tsx +39 -0
  37. package/templates/shadcn/src/lib/utils.ts +3 -0
  38. package/templates/shadcn/src/main.tsx +10 -0
  39. package/templates/shadcn/src/pages/Home.tsx +35 -0
  40. package/templates/shadcn/tailwind.config.ts +42 -0
  41. package/templates/shadcn/tsconfig.json +16 -0
  42. package/templates/shadcn/vite.config.ts +20 -0
  43. package/templates/space/.rulesync/skills/povio-ui-styling/SKILL.md +3 -0
  44. package/templates/space/apps/fe/src/styles/base.css +0 -19
  45. package/templates/space/package.json +1 -1
@@ -0,0 +1,336 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ failure,
5
+ initializeResult,
6
+ isNotification,
7
+ MCP_PROTOCOL_VERSION,
8
+ METHOD_NOT_FOUND,
9
+ negotiateProtocolVersion,
10
+ parseMessage,
11
+ takeCompleteLines,
12
+ toolResult,
13
+ } from "./mcp-protocol.js";
14
+ import {
15
+ backendPathError,
16
+ deployPathFor,
17
+ formatFileList,
18
+ formatPublishResult,
19
+ MCP_TOOLS,
20
+ parsePublishFile,
21
+ parsePublishFiles,
22
+ } from "./mcp-tools.js";
23
+ import {
24
+ callTool,
25
+ handleRequest,
26
+ NO_AUTH_ERROR,
27
+ NO_PROJECT_ERROR,
28
+ READ_NEEDS_USER_AUTH,
29
+ openSession,
30
+ } from "./mcp.js";
31
+ import type { McpSession } from "./mcp.js";
32
+
33
+ type Call = { path: string; init?: RequestInit };
34
+
35
+ function fakeSession(
36
+ responses: Record<string, unknown>,
37
+ calls: Call[] = [],
38
+ auth: "user" | "deploy-key" = "user",
39
+ ): McpSession {
40
+ return {
41
+ projectId: "prj-test",
42
+ auth,
43
+ request: async <T>(path: string, init?: RequestInit): Promise<T> => {
44
+ calls.push({ path, init });
45
+ const key = Object.keys(responses).find((candidate) => path.includes(candidate));
46
+ if (!key) throw new Error(`unexpected request: ${path}`);
47
+ return responses[key] as T;
48
+ },
49
+ };
50
+ }
51
+
52
+ function textOf(result: Record<string, unknown>): string {
53
+ const content = result.content as { type: string; text: string }[];
54
+ return content.map((part) => part.text).join("\n");
55
+ }
56
+
57
+ test("a stdio chunk is split into whole frames and the remainder is kept", () => {
58
+ const first = takeCompleteLines("", '{"a":1}\n{"b":2}\n{"c"');
59
+ assert.deepEqual(first.lines, ['{"a":1}', '{"b":2}']);
60
+ assert.equal(first.buffer, '{"c"');
61
+ const second = takeCompleteLines(first.buffer, ":3}\n");
62
+ assert.deepEqual(second.lines, ['{"c":3}']);
63
+ assert.equal(second.buffer, "");
64
+ });
65
+
66
+ test("malformed and non-request frames are ignored, not answered", () => {
67
+ assert.equal(parseMessage("not json"), null);
68
+ assert.equal(parseMessage("[1,2]"), null);
69
+ assert.equal(parseMessage('{"jsonrpc":"2.0","id":1}'), null);
70
+ assert.equal(parseMessage(" "), null);
71
+ const notification = parseMessage('{"jsonrpc":"2.0","method":"notifications/initialized"}');
72
+ assert.ok(notification);
73
+ assert.equal(isNotification(notification), true);
74
+ });
75
+
76
+ test("the client's protocol version is echoed when we speak it", () => {
77
+ assert.equal(negotiateProtocolVersion("2024-11-05"), "2024-11-05");
78
+ assert.equal(negotiateProtocolVersion("1999-01-01"), MCP_PROTOCOL_VERSION);
79
+ assert.equal(negotiateProtocolVersion(undefined), MCP_PROTOCOL_VERSION);
80
+ const result = initializeResult({
81
+ requestedProtocolVersion: "2025-03-26",
82
+ serverName: "robodev",
83
+ serverVersion: "1.2.3",
84
+ });
85
+ assert.equal(result.protocolVersion, "2025-03-26");
86
+ assert.deepEqual(result.serverInfo, { name: "robodev", version: "1.2.3" });
87
+ assert.deepEqual(result.capabilities, { tools: { listChanged: false } });
88
+ });
89
+
90
+ test("a notification is never answered and an unknown method is a protocol error", async () => {
91
+ const session = fakeSession({});
92
+ const notification = await handleRequest(session, {
93
+ jsonrpc: "2.0",
94
+ method: "notifications/initialized",
95
+ });
96
+ assert.equal(notification, null);
97
+ const unknown = await handleRequest(session, { jsonrpc: "2.0", id: 7, method: "resources/list" });
98
+ assert.equal(unknown?.error?.code, METHOD_NOT_FOUND);
99
+ assert.deepEqual(failure(1, METHOD_NOT_FOUND, "x").error, {
100
+ code: METHOD_NOT_FOUND,
101
+ message: "x",
102
+ });
103
+ });
104
+
105
+ test("tools/list advertises every tool with a usable schema", async () => {
106
+ const response = await handleRequest(fakeSession({}), {
107
+ jsonrpc: "2.0",
108
+ id: 1,
109
+ method: "tools/list",
110
+ });
111
+ const tools = (response?.result as { tools: typeof MCP_TOOLS }).tools;
112
+ assert.deepEqual(tools.map((tool) => tool.name).sort(), [
113
+ "list_backend_files",
114
+ "project_info",
115
+ "publish_backend_file",
116
+ "publish_backend_files",
117
+ "read_backend_file",
118
+ ]);
119
+ for (const tool of tools) {
120
+ assert.ok(tool.description.length > 20, `${tool.name} needs a real description`);
121
+ assert.equal((tool.inputSchema as { type: string }).type, "object");
122
+ }
123
+ });
124
+
125
+ test("publishing one file sends only that file and reports the live URL", async () => {
126
+ const calls: Call[] = [];
127
+ const session = fakeSession(
128
+ {
129
+ "/deploy/backend": {
130
+ published: ["packages/be/api/todos.ts"],
131
+ apiUrl: "https://prj-test.robodev.povio.dev",
132
+ docsUrl: "https://prj-test.robodev.povio.dev/_robodev/docs",
133
+ },
134
+ },
135
+ calls,
136
+ );
137
+ const result = await callTool(session, "publish_backend_file", {
138
+ path: "packages/be/api/todos.ts",
139
+ content: "export default defineApi({});",
140
+ });
141
+ assert.equal(result.isError, false);
142
+ assert.match(textOf(result), /Published 1 file and redeployed/);
143
+ assert.match(textOf(result), /packages\/be\/api\/todos\.ts/);
144
+ assert.match(textOf(result), /https:\/\/prj-test\.robodev\.povio\.dev/);
145
+
146
+ assert.equal(calls.length, 1, "one publish is one request — no full-tree round trip");
147
+ assert.equal(calls[0].path, "/v1/projects/prj-test/deploy/backend");
148
+ const body = JSON.parse(String(calls[0].init?.body)) as {
149
+ files: { path: string; content: string }[];
150
+ force: boolean;
151
+ };
152
+ assert.deepEqual(body.files, [
153
+ { path: "packages/be/api/todos.ts", content: "export default defineApi({});" },
154
+ ]);
155
+ assert.equal(body.force, false, "a destructive schema change stays opt-in");
156
+ });
157
+
158
+ test("force is passed through only when asked for", async () => {
159
+ const calls: Call[] = [];
160
+ const session = fakeSession(
161
+ { "/deploy/backend": { published: ["packages/be/database.ts"] } },
162
+ calls,
163
+ );
164
+ await callTool(session, "publish_backend_file", {
165
+ path: "packages/be/database.ts",
166
+ content: "x",
167
+ force: true,
168
+ });
169
+ assert.equal(JSON.parse(String(calls[0].init?.body)).force, true);
170
+ });
171
+
172
+ test("a route and its schema publish together in one deploy", async () => {
173
+ const calls: Call[] = [];
174
+ const session = fakeSession(
175
+ { "/deploy/backend": { published: ["packages/be/api/todos.ts", "packages/be/database.ts"] } },
176
+ calls,
177
+ );
178
+ const result = await callTool(session, "publish_backend_files", {
179
+ files: [
180
+ { path: "packages/be/api/todos.ts", content: "a" },
181
+ { path: "packages/be/database.ts", content: "b" },
182
+ ],
183
+ });
184
+ assert.equal(calls.length, 1, "one deploy, so the schema and the route never disagree");
185
+ assert.match(textOf(result), /Published 2 files/);
186
+ });
187
+
188
+ test("frontend paths are refused with the command that does handle them", async () => {
189
+ const session = fakeSession({});
190
+ for (const path of ["apps/fe/src/App.tsx", "src/main.tsx", "index.html", "public/logo.png"]) {
191
+ const result = await callTool(session, "publish_backend_file", { path, content: "x" });
192
+ assert.equal(result.isError, true, `${path} must be refused`);
193
+ assert.match(textOf(result), /robodev deploy/);
194
+ }
195
+ assert.equal(backendPathError("packages/be/api/todos.ts"), null);
196
+ assert.equal(backendPathError("packages/be/database.ts"), null);
197
+ assert.equal(backendPathError("packages/be/package.json"), null);
198
+ });
199
+
200
+ test("bad tool arguments come back as a readable tool error, not a crash", async () => {
201
+ const session = fakeSession({});
202
+ assert.equal(typeof parsePublishFile({ path: "a.ts" }), "string");
203
+ assert.equal(typeof parsePublishFile({ content: "x" }), "string");
204
+ assert.equal(typeof parsePublishFiles([]), "string");
205
+ assert.equal(typeof parsePublishFiles("nope"), "string");
206
+ assert.equal(typeof parsePublishFiles(Array(51).fill({ path: "a.ts", content: "x" })), "string");
207
+ const missing = await callTool(session, "read_backend_file", {});
208
+ assert.equal(missing.isError, true);
209
+ const unknown = await callTool(session, "no_such_tool", {});
210
+ assert.equal(unknown.isError, true);
211
+ });
212
+
213
+ test("a Starbase rejection is surfaced to the agent as a tool error it can act on", async () => {
214
+ const session: McpSession = {
215
+ projectId: "prj-test",
216
+ auth: "user",
217
+ request: async () => {
218
+ const { ApiError } = await import("./api.js");
219
+ throw new ApiError("destructive change requires force", 400, {
220
+ message: "dropping column todos.done",
221
+ });
222
+ },
223
+ };
224
+ const response = await handleRequest(session, {
225
+ jsonrpc: "2.0",
226
+ id: 3,
227
+ method: "tools/call",
228
+ params: {
229
+ name: "publish_backend_file",
230
+ arguments: { path: "packages/be/database.ts", content: "x" },
231
+ },
232
+ });
233
+ // Reported in the result, not as a JSON-RPC error: the agent should read it and retry.
234
+ assert.equal(response?.error, undefined);
235
+ const result = response?.result as Record<string, unknown>;
236
+ assert.equal(result.isError, true);
237
+ assert.match(textOf(result), /400/);
238
+ assert.match(textOf(result), /dropping column todos\.done/);
239
+ });
240
+
241
+ test("listing and reading see the live backend tree and hide the frontend", async () => {
242
+ const session = fakeSession({
243
+ "/deployment/files": {
244
+ files: [
245
+ { path: "database.ts", content: "export const db = 1;" },
246
+ { path: "api/todos.ts", content: "route" },
247
+ { path: "src/main.tsx", content: "frontend" },
248
+ { path: "index.html", content: "<html></html>" },
249
+ ],
250
+ },
251
+ });
252
+ const listed = textOf(await callTool(session, "list_backend_files", {}));
253
+ assert.match(listed, /2 backend files live/);
254
+ assert.match(listed, /database\.ts \(20 bytes\)/);
255
+ assert.equal(listed.includes("src/main.tsx"), false);
256
+ assert.equal(listed.includes("index.html"), false);
257
+
258
+ // A Tiny workspace path and the deployed path name the same file.
259
+ assert.equal(deployPathFor("packages/be/api/todos.ts"), "api/todos.ts");
260
+ const read = await callTool(session, "read_backend_file", { path: "packages/be/api/todos.ts" });
261
+ assert.equal(textOf(read), "route");
262
+ const readDeployPath = await callTool(session, "read_backend_file", { path: "api/todos.ts" });
263
+ assert.equal(textOf(readDeployPath), "route");
264
+ const absent = await callTool(session, "read_backend_file", { path: "packages/be/api/none.ts" });
265
+ assert.equal(absent.isError, true);
266
+ assert.match(textOf(absent), /list_backend_files/);
267
+ });
268
+
269
+ test("an empty deployment tells you to deploy once first", () => {
270
+ assert.match(formatFileList([]), /robodev deploy/);
271
+ assert.match(formatPublishResult({ published: ["a.ts"] }), /Published 1 file/);
272
+ assert.equal(toolResult("x").isError, false);
273
+ });
274
+
275
+ test("project_info names the project and how the session authenticated", async () => {
276
+ const asKey = await callTool(fakeSession({}, [], "deploy-key"), "project_info", {});
277
+ assert.match(textOf(asKey), /prj-test/);
278
+ assert.match(textOf(asKey), /deploy key/);
279
+ const asUser = await callTool(fakeSession({}), "project_info", {});
280
+ assert.match(textOf(asUser), /signed-in user/);
281
+ });
282
+
283
+ test("an unlinked folder says how to link, before any request is made", async () => {
284
+ await assert.rejects(
285
+ () => openSession({ cwd: "/tmp/robodev-not-a-project", deployKey: "" }),
286
+ (error: unknown) => error instanceof Error && error.message === NO_PROJECT_ERROR,
287
+ );
288
+ });
289
+
290
+ test("a deploy key authenticates without robodev auth", async () => {
291
+ const session = await openSession({
292
+ projectId: "prj-headless",
293
+ deployKey: " rdk_test ",
294
+ cwd: "/tmp/robodev-not-a-project",
295
+ });
296
+ assert.equal(session.auth, "deploy-key");
297
+ assert.equal(session.projectId, "prj-headless");
298
+ assert.match(NO_AUTH_ERROR, /ROBODEV_DEPLOY_KEY/);
299
+ });
300
+
301
+ test("a deploy-key session can publish but is told plainly it cannot read", async () => {
302
+ // GET /deployment/files is member-only on purpose: a leaked CI credential should not be able to
303
+ // dump the project's backend source. Publishing never needed it — the server reads the live tree
304
+ // itself — so only the read tools lose out, and they have to say why.
305
+ const calls: Call[] = [];
306
+ const session = fakeSession(
307
+ { "/deploy/backend": { published: ["api/todos.ts"] } },
308
+ calls,
309
+ "deploy-key",
310
+ );
311
+
312
+ for (const tool of ["list_backend_files", "read_backend_file"]) {
313
+ const result = await callTool(session, tool, { path: "api/todos.ts" });
314
+ assert.equal(result.isError, true, `${tool} must refuse a deploy-key session`);
315
+ assert.equal(textOf(result), READ_NEEDS_USER_AUTH);
316
+ assert.match(textOf(result), /robodev auth/);
317
+ }
318
+ assert.equal(calls.length, 0, "it refuses locally rather than relaying a 403");
319
+
320
+ // Publishing still works with the same session.
321
+ const published = await callTool(session, "publish_backend_file", {
322
+ path: "api/todos.ts",
323
+ content: "x",
324
+ });
325
+ assert.equal(published.isError, false);
326
+ assert.equal(calls.length, 1);
327
+ });
328
+
329
+ test("a signed-in session reads normally", async () => {
330
+ const session = fakeSession({
331
+ "/deployment/files": { files: [{ path: "api/todos.ts", content: "route" }] },
332
+ });
333
+ const result = await callTool(session, "read_backend_file", { path: "api/todos.ts" });
334
+ assert.equal(result.isError, false);
335
+ assert.equal(textOf(result), "route");
336
+ });
package/src/mcp.ts ADDED
@@ -0,0 +1,281 @@
1
+ import { stdin, stdout } from "node:process";
2
+ import { ApiError, authed, deployKeyRequest } from "./api.js";
3
+ import { readCredentials, readLink, starbaseUrl } from "./config.js";
4
+ import {
5
+ failure,
6
+ initializeResult,
7
+ INTERNAL_ERROR,
8
+ INVALID_PARAMS,
9
+ isNotification,
10
+ METHOD_NOT_FOUND,
11
+ parseMessage,
12
+ success,
13
+ takeCompleteLines,
14
+ toolResult,
15
+ type JsonRpcRequest,
16
+ type JsonRpcResponse,
17
+ } from "./mcp-protocol.js";
18
+ import {
19
+ backendPathError,
20
+ deployPathFor,
21
+ formatFileList,
22
+ formatPublishResult,
23
+ MCP_SERVER_INSTRUCTIONS,
24
+ MCP_TOOLS,
25
+ parsePublishFile,
26
+ parsePublishFiles,
27
+ type PublishFile,
28
+ } from "./mcp-tools.js";
29
+
30
+ export const SERVER_NAME = "robodev";
31
+
32
+ type DeployResponse = {
33
+ published?: string[];
34
+ apiUrl?: string;
35
+ appUrl?: string;
36
+ docsUrl?: string;
37
+ };
38
+
39
+ export type McpSession = {
40
+ projectId: string;
41
+ /** How the session talks to Starbase: the signed-in user, or a project deploy key. */
42
+ auth: "user" | "deploy-key";
43
+ request: <T>(path: string, init?: RequestInit) => Promise<T>;
44
+ };
45
+
46
+ export const NO_PROJECT_ERROR =
47
+ "No project is linked here. Run `robodev link <projectId>` in the project folder, or pass --project <projectId>.";
48
+ export const NO_AUTH_ERROR =
49
+ "Not authenticated. Run `robodev auth`, or set ROBODEV_DEPLOY_KEY to a project deploy key.";
50
+
51
+ export function parseMcpArgs(argv: readonly string[]): { projectId?: string } {
52
+ const out: { projectId?: string } = {};
53
+ for (let index = 0; index < argv.length; index++) {
54
+ if (argv[index] !== "--project") continue;
55
+ const value = (argv[index + 1] ?? "").trim();
56
+ if (value) out.projectId = value;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * A deploy key is the headless path — an agent runner has no browser to complete `robodev auth`
63
+ * with — and it wins when set, because it names the project it is scoped to.
64
+ */
65
+ export async function openSession(input: {
66
+ projectId?: string;
67
+ deployKey?: string;
68
+ cwd?: string;
69
+ }): Promise<McpSession> {
70
+ const link = await readLink(input.cwd ?? process.cwd());
71
+ const projectId = input.projectId ?? link?.projectId;
72
+ if (!projectId) throw new Error(NO_PROJECT_ERROR);
73
+
74
+ const deployKey = (input.deployKey ?? "").trim();
75
+ if (deployKey) {
76
+ return {
77
+ projectId,
78
+ auth: "deploy-key",
79
+ request: (path, init) => deployKeyRequest(path, deployKey, init),
80
+ };
81
+ }
82
+ if (!(await readCredentials())) throw new Error(NO_AUTH_ERROR);
83
+ return { projectId, auth: "user", request: (path, init) => authed(path, init) };
84
+ }
85
+
86
+ /**
87
+ * GET /deployment/files is member-only: a leaked CI credential should not be able to dump the
88
+ * project's backend source. Publishing never needed it — the server reads the live tree itself —
89
+ * so only the read tools lose out, and they refuse locally rather than relaying a 403.
90
+ */
91
+ export const READ_NEEDS_USER_AUTH =
92
+ "Reading deployed files needs a signed-in user: run `robodev auth` and start this server without ROBODEV_DEPLOY_KEY. A deploy key can publish, but not read the project's source. Publishing does not require reading first.";
93
+
94
+ function canRead(session: McpSession): boolean {
95
+ return session.auth === "user";
96
+ }
97
+
98
+ async function liveBackendFiles(session: McpSession): Promise<PublishFile[]> {
99
+ const body = await session.request<{ files?: PublishFile[] }>(
100
+ `/v1/projects/${session.projectId}/deployment/files`,
101
+ );
102
+ const files = body.files ?? [];
103
+ return files.filter((file) => backendPathError(file.path) === null);
104
+ }
105
+
106
+ async function publish(
107
+ session: McpSession,
108
+ files: readonly PublishFile[],
109
+ force: boolean,
110
+ ): Promise<Record<string, unknown>> {
111
+ for (const file of files) {
112
+ const error = backendPathError(file.path);
113
+ if (error) return toolResult(error, true);
114
+ }
115
+ const body = await session.request<DeployResponse>(
116
+ `/v1/projects/${session.projectId}/deploy/backend`,
117
+ { method: "POST", body: JSON.stringify({ files, force }) },
118
+ );
119
+ return toolResult(
120
+ formatPublishResult({
121
+ published: body.published ?? files.map((file) => file.path),
122
+ apiUrl: body.apiUrl,
123
+ docsUrl: body.docsUrl,
124
+ }),
125
+ );
126
+ }
127
+
128
+ function asBoolean(value: unknown): boolean {
129
+ return value === true;
130
+ }
131
+
132
+ export async function callTool(
133
+ session: McpSession,
134
+ name: string,
135
+ args: Record<string, unknown>,
136
+ ): Promise<Record<string, unknown>> {
137
+ if (name === "publish_backend_file") {
138
+ const file = parsePublishFile(args);
139
+ if (typeof file === "string") return toolResult(file, true);
140
+ return publish(session, [file], asBoolean(args.force));
141
+ }
142
+
143
+ if (name === "publish_backend_files") {
144
+ const files = parsePublishFiles(args.files);
145
+ if (typeof files === "string") return toolResult(files, true);
146
+ return publish(session, files, asBoolean(args.force));
147
+ }
148
+
149
+ if (name === "list_backend_files") {
150
+ if (!canRead(session)) return toolResult(READ_NEEDS_USER_AUTH, true);
151
+ return toolResult(formatFileList(await liveBackendFiles(session)));
152
+ }
153
+
154
+ if (name === "read_backend_file") {
155
+ if (!canRead(session)) return toolResult(READ_NEEDS_USER_AUTH, true);
156
+ const path = typeof args.path === "string" ? args.path.trim() : "";
157
+ if (!path) return toolResult("`path` is required.", true);
158
+ const pathError = backendPathError(path);
159
+ if (pathError) return toolResult(pathError, true);
160
+ const deployPath = deployPathFor(path);
161
+ const files = await liveBackendFiles(session);
162
+ const found = files.find((file) => file.path === deployPath);
163
+ if (!found) {
164
+ return toolResult(
165
+ `${path} is not deployed${deployPath === path ? "" : ` (as ${deployPath})`}. list_backend_files shows what is live.`,
166
+ true,
167
+ );
168
+ }
169
+ return toolResult(found.content);
170
+ }
171
+
172
+ if (name === "project_info") {
173
+ const projectApiUrl = `${starbaseUrl()}`;
174
+ return toolResult(
175
+ [
176
+ `Project: ${session.projectId}`,
177
+ `Starbase: ${projectApiUrl}`,
178
+ `Authenticated as: ${session.auth === "user" ? "signed-in user" : "project deploy key"}`,
179
+ ].join("\n"),
180
+ );
181
+ }
182
+
183
+ return toolResult(`Unknown tool: ${name}`, true);
184
+ }
185
+
186
+ function toolCallError(error: unknown): Record<string, unknown> {
187
+ if (error instanceof ApiError) {
188
+ const detail = typeof error.body.message === "string" ? error.body.message : error.message;
189
+ return toolResult(`Starbase rejected the publish (${error.status}): ${detail}`, true);
190
+ }
191
+ return toolResult(error instanceof Error ? error.message : "Publish failed.", true);
192
+ }
193
+
194
+ export async function handleRequest(
195
+ session: McpSession,
196
+ request: JsonRpcRequest,
197
+ ): Promise<JsonRpcResponse | null> {
198
+ if (isNotification(request)) return null;
199
+ const id = request.id as string | number;
200
+
201
+ if (request.method === "initialize") {
202
+ return success(
203
+ id,
204
+ initializeResult({
205
+ requestedProtocolVersion: request.params?.protocolVersion,
206
+ serverName: SERVER_NAME,
207
+ serverVersion: await serverVersion(),
208
+ instructions: MCP_SERVER_INSTRUCTIONS,
209
+ }),
210
+ );
211
+ }
212
+ if (request.method === "ping") return success(id, {});
213
+ if (request.method === "tools/list") return success(id, { tools: MCP_TOOLS });
214
+ if (request.method === "tools/call") {
215
+ const name = typeof request.params?.name === "string" ? request.params.name : "";
216
+ if (!name) return failure(id, INVALID_PARAMS, "tools/call requires a tool name");
217
+ const args =
218
+ request.params?.arguments && typeof request.params.arguments === "object"
219
+ ? (request.params.arguments as Record<string, unknown>)
220
+ : {};
221
+ try {
222
+ return success(id, await callTool(session, name, args));
223
+ } catch (error) {
224
+ // A failing tool is reported in the result, not as a protocol error: the agent should read
225
+ // the reason and try again rather than see the server as broken.
226
+ return success(id, toolCallError(error));
227
+ }
228
+ }
229
+ return failure(id, METHOD_NOT_FOUND, `Unknown method: ${request.method}`);
230
+ }
231
+
232
+ let cachedVersion: string | null = null;
233
+
234
+ async function serverVersion(): Promise<string> {
235
+ if (cachedVersion) return cachedVersion;
236
+ try {
237
+ const { readFile } = await import("node:fs/promises");
238
+ const { fileURLToPath } = await import("node:url");
239
+ const { dirname, join } = await import("node:path");
240
+ const here = dirname(fileURLToPath(import.meta.url));
241
+ const raw = await readFile(join(here, "..", "package.json"), "utf8");
242
+ cachedVersion = String((JSON.parse(raw) as { version?: string }).version ?? "0.0.0");
243
+ } catch {
244
+ cachedVersion = "0.0.0";
245
+ }
246
+ return cachedVersion;
247
+ }
248
+
249
+ /**
250
+ * Serves MCP on stdio. stdout carries protocol frames only — anything this server wants to say to
251
+ * a human goes to stderr, or it corrupts the stream.
252
+ */
253
+ export async function runMcp(argv: readonly string[]): Promise<void> {
254
+ const { projectId } = parseMcpArgs(argv);
255
+ const session = await openSession({
256
+ projectId,
257
+ deployKey: process.env.ROBODEV_DEPLOY_KEY,
258
+ });
259
+ console.error(`robodev mcp: project ${session.projectId} on ${starbaseUrl()} (${session.auth})`);
260
+
261
+ let buffer = "";
262
+ stdin.setEncoding("utf8");
263
+ for await (const chunk of stdin) {
264
+ const taken = takeCompleteLines(buffer, chunk as string);
265
+ buffer = taken.buffer;
266
+ for (const line of taken.lines) {
267
+ const request = parseMessage(line);
268
+ if (!request) continue;
269
+ const response = await handleRequest(session, request).catch((error: unknown) =>
270
+ request.id === undefined
271
+ ? null
272
+ : failure(
273
+ request.id,
274
+ INTERNAL_ERROR,
275
+ error instanceof Error ? error.message : "Internal error",
276
+ ),
277
+ );
278
+ if (response) stdout.write(`${JSON.stringify(response)}\n`);
279
+ }
280
+ }
281
+ }
@@ -132,6 +132,9 @@ Do not create a CSS file for ordinary spacing, flex/grid layout, a simple hover,
132
132
  | Generated design tokens | Figma-generated `theme.css` |
133
133
  | Handwritten global token or browser rule | `base.css` or `globals.css` |
134
134
 
135
+ Retheming the app means overriding tokens in `globals.css`, in `@layer base`, after the
136
+ `theme.css` import — nowhere else redeclares them, so the last declaration in that layer wins.
137
+
135
138
  ## Guardrails
136
139
 
137
140
  - Use `@povio/ui` before creating a custom primitive.
@@ -82,22 +82,3 @@ html {
82
82
  .flex-fill {
83
83
  flex: 1 0 0;
84
84
  }
85
-
86
- :root {
87
- --elevation-fill-default-2-base: 248 249 249;
88
- --elevation-outline-default-1-base: 218 220 221;
89
- --elevation-outline-default-2-base: 198 200 201;
90
- --interactive-contained-primary-idle-base: 0 90 154;
91
- --interactive-contained-primary-hover-base: 46 142 210;
92
- --interactive-contained-primary-pressed-base: 95 170 224;
93
- --interactive-contained-primary-disabled-base: 155 194 221;
94
- --interactive-contained-primary-focus-base: 95 170 224;
95
- --interactive-outlined-primary-on-idle-base: 0 90 154;
96
- --interactive-outlined-primary-outline-idle-base: 0 90 154;
97
- --interactive-outlined-primary-outline-hover-base: 46 142 210;
98
- --interactive-text-primary-idle-base: 0 90 154;
99
- --interactive-text-primary-hover-base: 46 142 210;
100
- --text-default-1-base: 25 26 27;
101
- --text-default-2-base: 79 85 88;
102
- --text-default-3-base: 111 114 116;
103
- }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.13.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.45.1",
15
+ "robodev": "^0.47.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.13.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.45.1",
14
+ "robodev": "^0.47.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -25,6 +25,11 @@
25
25
  "id": "empty",
26
26
  "title": "Empty",
27
27
  "description": "Blank database.ts and GET /api/health plus a hosted Welcome page. No sample schema."
28
+ },
29
+ {
30
+ "id": "shadcn",
31
+ "title": "Shadcn",
32
+ "description": "Blank database.ts and GET /api/health plus a hosted Welcome page with shadcn and an orval client. No sample schema."
28
33
  }
29
34
  ]
30
35
  }
@@ -16,7 +16,7 @@
16
16
  "react-dom": "^18.3.1"
17
17
  },
18
18
  "devDependencies": {
19
- "robodev": "^0.45.1",
19
+ "robodev": "^0.47.0",
20
20
  "typescript": "^5.9.2",
21
21
  "vite": "^6.0.0"
22
22
  }