robodev 0.51.1 → 0.52.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robodev",
3
- "version": "0.51.1",
3
+ "version": "0.52.0",
4
4
  "description": "CLI for Robodev Starbase — create, auth, link, deploy, load, and run a project locally with robodev dev",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,8 +26,8 @@
26
26
  "embedded-postgres": "17.10.0-beta.17",
27
27
  "fastify": "^5.12.1",
28
28
  "tsx": "^4.20.5",
29
- "@robodev-ai/sdk": "0.15.1",
30
- "@robodev-ai/runtime": "0.11.1"
29
+ "@robodev-ai/runtime": "0.12.0",
30
+ "@robodev-ai/sdk": "0.16.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^24.3.0",
@@ -149,7 +149,8 @@ function isBackendPath(path: string): boolean {
149
149
  inner.startsWith("api/") ||
150
150
  inner.startsWith("jobs/") ||
151
151
  inner.startsWith("hooks/") ||
152
- inner.startsWith("sockets/")
152
+ inner.startsWith("sockets/") ||
153
+ inner.startsWith("mcp/")
153
154
  ) {
154
155
  return inner.endsWith(".ts") && !isBackendTestPath(inner);
155
156
  }
@@ -175,13 +176,18 @@ export function mixedBackendLayoutError(paths: Iterable<string>): string | null
175
176
  const hasNewDb = list.includes("robodev/database.ts");
176
177
  const hasLegacyDb = list.includes("database.ts");
177
178
  const hasLegacyApiJobs = list.some(
178
- (path) => path.startsWith("api/") || path.startsWith("jobs/") || path.startsWith("sockets/"),
179
+ (path) =>
180
+ path.startsWith("api/") ||
181
+ path.startsWith("jobs/") ||
182
+ path.startsWith("sockets/") ||
183
+ path.startsWith("mcp/"),
179
184
  );
180
185
  const hasNewApiJobs = list.some(
181
186
  (path) =>
182
187
  path.startsWith("robodev/api/") ||
183
188
  path.startsWith("robodev/jobs/") ||
184
- path.startsWith("robodev/sockets/"),
189
+ path.startsWith("robodev/sockets/") ||
190
+ path.startsWith("robodev/mcp/"),
185
191
  );
186
192
  if (hasNewDb && (hasLegacyDb || hasLegacyApiJobs)) {
187
193
  return "Do not mix robodev/ backend files with root database.ts, api/, or jobs/";
@@ -429,3 +429,18 @@ test("pdf inspect records byte length and format, not HTML text", () => {
429
429
  assert.equal(event.error, "pdf_failed");
430
430
  assert.equal(JSON.stringify(event).includes("secret invoice"), false);
431
431
  });
432
+
433
+ test("dashboard links the MCP endpoint only when the project has tools", async () => {
434
+ const runtime = {
435
+ routes: [{ method: "get", path: "/api/planets", file: "api/planets.ts" }],
436
+ jobs: [],
437
+ };
438
+ const without = await buildDashboardState({ inspect: createDevInspect(), runtime });
439
+ assert.equal(without.overview.links.mcp, undefined);
440
+ const withTools = await buildDashboardState({
441
+ inspect: createDevInspect(),
442
+ runtime: { ...runtime, mcpTools: [{ name: "get_planets" }] },
443
+ });
444
+ assert.equal(withTools.overview.links.mcp, "/_robodev/mcp");
445
+ assert.equal(isHistogramExcludedPath("/_robodev/mcp"), true);
446
+ });
@@ -42,6 +42,7 @@ const HISTOGRAM_EXCLUDED_PATHS = new Set([
42
42
  "/openapi.json",
43
43
  "/robodev-enterprise-spec.json",
44
44
  "/_robodev/ready",
45
+ "/_robodev/mcp",
45
46
  ]);
46
47
 
47
48
  export const RESERVED_AUTH_ROUTES: Array<{ method: string; path: string; file: string }> = [
@@ -214,7 +215,14 @@ export type DashboardState = {
214
215
  google: DashboardGoogle;
215
216
  requestHistogram: RequestHistogramBucket[];
216
217
  jobHistogram: JobHistogramBucket[];
217
- links: { swagger: string; scalar: string; openapi: string; enterprise: string };
218
+ links: {
219
+ swagger: string;
220
+ scalar: string;
221
+ openapi: string;
222
+ enterprise: string;
223
+ /** Present when the project has MCP tools. */
224
+ mcp?: string;
225
+ };
218
226
  };
219
227
  routes: Array<{ method: string; path: string; file: string }>;
220
228
  calls: Array<{ at: number; method: string; path: string; status: number; durationMs: number }>;
@@ -246,6 +254,7 @@ export type DashboardRuntime = {
246
254
  routes: Array<{ method: string; path: string; file: string }>;
247
255
  jobs: Array<{ name: string; file: string; def: { cron?: string } }>;
248
256
  sockets?: Array<{ name: string; path: string; file: string; def: { auth?: ApiAuth } }>;
257
+ mcpTools?: Array<{ name: string }>;
249
258
  pool?: {
250
259
  query: <R extends Record<string, unknown> = Record<string, unknown>>(
251
260
  sql: string,
@@ -602,6 +611,7 @@ export async function buildDashboardState(input: {
602
611
  scalar: "/_robodev/scalar",
603
612
  openapi: "/openapi.json",
604
613
  enterprise: "/robodev-enterprise-spec.json",
614
+ ...(input.runtime?.mcpTools?.length ? { mcp: "/_robodev/mcp" } : {}),
605
615
  },
606
616
  },
607
617
  routes,
@@ -1076,6 +1086,9 @@ export function inspectHtml(): string {
1076
1086
  { href: state.overview.links.openapi, label: "openapi.json" },
1077
1087
  { href: state.overview.links.enterprise, label: "Enterprise spec" }
1078
1088
  ];
1089
+ if (state.overview.links.mcp) {
1090
+ items.push({ href: state.overview.links.mcp, label: "MCP (POST)" });
1091
+ }
1079
1092
  for (var i = 0; i < items.length; i++) {
1080
1093
  var a = document.createElement("a");
1081
1094
  a.href = items[i].href;
@@ -7,14 +7,14 @@ export type DevFile = { path: string; content: string };
7
7
  export type DevProject = {
8
8
  /** Where `.robodev/dev/` and the root `package.json` live. */
9
9
  root: string;
10
- /** Absolute directory holding `database.ts` plus `api/`, `jobs/`, `hooks/`, `sockets/`. */
10
+ /** Absolute directory holding `database.ts` plus `api/`, `jobs/`, `hooks/`, `sockets/`, `mcp/`. */
11
11
  backendDir: string;
12
12
  /** Backend layout that was matched, for the startup banner. */
13
13
  layout: "root" | "robodev" | "packages/be";
14
14
  };
15
15
 
16
16
  /** Directories under the backend root that hold compilable project code. */
17
- export const BACKEND_DIRS = ["api", "jobs", "hooks", "sockets"] as const;
17
+ export const BACKEND_DIRS = ["api", "jobs", "hooks", "sockets", "mcp"] as const;
18
18
 
19
19
  const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".robodev"]);
20
20
  const MAX_BACKEND_FILES = 400;
package/src/dev-server.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { Readable } from "node:stream";
2
3
  import cors from "@fastify/cors";
3
4
  import websocket from "@fastify/websocket";
@@ -13,7 +14,25 @@ import {
13
14
  buildOpenApi,
14
15
  buildEnterpriseSpec,
15
16
  handleLocalAuth,
17
+ handleProjectMcpHttp,
18
+ handleProjectOAuth,
16
19
  isLocalAuthPath,
20
+ isProjectMcpPath,
21
+ isProjectOAuthRequestPath,
22
+ localPasswordSignIn,
23
+ localPasswordSignUp,
24
+ authenticateProjectMcpToken,
25
+ isProjectMcpAccessToken,
26
+ projectGoogleOverrideMode,
27
+ projectMcpResource,
28
+ revokeRefresh,
29
+ runMcpOp,
30
+ runtimeVersion,
31
+ verifyProjectUserToken,
32
+ type McpCaller,
33
+ type OAuthHttpResponse,
34
+ type ProjectMcpHttpResponse,
35
+ type RuntimeMcpTool,
17
36
  isReservedStoragePath,
18
37
  ENTERPRISE_SPEC_PATH,
19
38
  matchProjectRoute,
@@ -53,6 +72,7 @@ export type DevRuntime = {
53
72
  routes: RuntimeRoute[];
54
73
  jobs: RuntimeJob[];
55
74
  sockets: RuntimeSocket[];
75
+ mcpTools: RuntimeMcpTool[];
56
76
  openapi: Record<string, unknown>;
57
77
  };
58
78
 
@@ -70,13 +90,76 @@ export type DevServerOptions = {
70
90
  googleClientSecret?: string;
71
91
  storageDir: string;
72
92
  projectRoot?: string;
93
+ /** Shown on the MCP consent page and as the MCP server name. */
94
+ projectName?: string;
73
95
  };
74
96
 
75
97
  const SWAGGER_PATHS = new Set(["/_robodev/docs", "/_robodev/swagger", "/docs", "/swagger"]);
76
98
  const SCALAR_PATHS = new Set(["/_robodev/scalar", "/scalar"]);
77
99
 
78
- export function buildDevOpenApi(projectName: string, routes: RuntimeRoute[]) {
79
- return buildOpenApi({ title: `${projectName} APIs`, projectId: "local", routes });
100
+ export function buildDevOpenApi(
101
+ projectName: string,
102
+ routes: RuntimeRoute[],
103
+ mcpTools: RuntimeMcpTool[] = [],
104
+ ) {
105
+ return buildOpenApi({ title: `${projectName} APIs`, projectId: "local", routes, mcpTools });
106
+ }
107
+
108
+ /** The URL MCP clients connect to, printed in the banner. */
109
+ export function devMcpUrl(listenOrigin: string): string {
110
+ return `${listenOrigin}/_robodev/mcp`;
111
+ }
112
+
113
+ function devOAuthSecret(options: DevServerOptions): string {
114
+ return createHash("sha256")
115
+ .update(`${options.jwtSecret}:${options.projectId}:project-mcp-oauth`)
116
+ .digest("base64url");
117
+ }
118
+
119
+ async function sendRuntimeResponse(
120
+ reply: FastifyReply,
121
+ response: ProjectMcpHttpResponse | OAuthHttpResponse,
122
+ ): Promise<void> {
123
+ reply.code(response.status);
124
+ for (const [name, value] of Object.entries(response.headers)) reply.header(name, value);
125
+ await (response.body === undefined ? reply.send() : reply.send(response.body));
126
+ }
127
+
128
+ /**
129
+ * `robodev dev` has no project Auth settings store: Auth is on, password sign-in and sign-up
130
+ * are on, and Google is on unless the project `.env` sets only one of its two keys.
131
+ */
132
+ function devOAuthSettings(options: DevServerOptions) {
133
+ return {
134
+ enabled: true,
135
+ emailPasswordEnabled: true,
136
+ allowSignups: true,
137
+ googleEnabled:
138
+ projectGoogleOverrideMode(options.googleClientId, options.googleClientSecret) !== "one",
139
+ };
140
+ }
141
+
142
+ async function devMcpCaller(
143
+ options: DevServerOptions,
144
+ runtime: DevRuntime,
145
+ token: string,
146
+ ): Promise<McpCaller> {
147
+ if (isProjectMcpAccessToken(token)) {
148
+ const principal = await authenticateProjectMcpToken(
149
+ runtime.pool,
150
+ token,
151
+ projectMcpResource(options.listenOrigin),
152
+ );
153
+ return principal
154
+ ? { user: principal.user, via: "mcp_token", invalid: false }
155
+ : { user: null, via: null, invalid: true };
156
+ }
157
+ try {
158
+ const user = await verifyProjectUserToken(token, options.projectId, options.jwtSecret);
159
+ return { user, via: "project_jwt", invalid: false };
160
+ } catch {
161
+ return { user: null, via: null, invalid: true };
162
+ }
80
163
  }
81
164
 
82
165
  function pathnameOf(url: string): string {
@@ -453,6 +536,8 @@ export async function startDevServer(
453
536
  origin: true,
454
537
  credentials: true,
455
538
  methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
539
+ // Browser MCP clients (MCP Inspector) read the 401 challenge to find the OAuth server.
540
+ exposedHeaders: ["WWW-Authenticate", "Mcp-Session-Id"],
456
541
  });
457
542
  await app.register(websocket);
458
543
  app.get("/sockets/*", { websocket: true }, (socket, request) => {
@@ -631,6 +716,108 @@ export async function startDevServer(
631
716
  return;
632
717
  }
633
718
 
719
+ if (isProjectMcpPath(pathname)) {
720
+ const headers = request.headers as Record<string, string | string[] | undefined>;
721
+ const clients = () => routeClients(options, getRuntime);
722
+ const response = await handleProjectMcpHttp(
723
+ { method: request.method, headers, body: request.body },
724
+ {
725
+ tools: runtime.mcpTools,
726
+ serverName: options.projectName ?? "robodev-dev",
727
+ serverVersion: runtimeVersion(),
728
+ authEnabled: true,
729
+ origin: options.listenOrigin,
730
+ resolveCaller: (token) => devMcpCaller(options, runtime, token),
731
+ verifyTools: async (names, requestHeaders) => {
732
+ const outcome = await runMcpOp({
733
+ db: runtime.db,
734
+ mcpTools: runtime.mcpTools,
735
+ op: { op: "verify", tools: names },
736
+ headers: requestHeaders,
737
+ clients,
738
+ });
739
+ const payload = outcome.kind === "ok" ? outcome.encoded.payload : null;
740
+ return Array.isArray(payload) ? payload.map(Boolean) : names.map(() => false);
741
+ },
742
+ invokeRoute: async (input) => {
743
+ const started = Date.now();
744
+ const outcome = await runRoute({
745
+ db: runtime.db,
746
+ routes: runtime.routes,
747
+ method: input.method,
748
+ pathname: input.pathname,
749
+ query: input.query,
750
+ body: input.body,
751
+ headers: input.headers,
752
+ authorization: request.headers.authorization,
753
+ resolveUser: async () => null,
754
+ user: input.user,
755
+ clients,
756
+ mapError: (error) => codedErrorBody(error),
757
+ });
758
+ const status = outcome.kind === "ok" ? outcome.encoded.status : outcome.status;
759
+ const durationMs = Date.now() - started;
760
+ options.inspect.record({
761
+ kind: "request",
762
+ method: input.method.toUpperCase(),
763
+ path: input.pathname,
764
+ status,
765
+ durationMs,
766
+ });
767
+ console.log(
768
+ `${input.method.toUpperCase()} ${input.pathname} ${status} ${durationMs}ms (mcp ${input.tool.name})`,
769
+ );
770
+ return outcome;
771
+ },
772
+ invokeTool: (input) =>
773
+ runMcpOp({
774
+ db: runtime.db,
775
+ mcpTools: runtime.mcpTools,
776
+ op: { op: "tool", tool: input.tool.name, input: input.input, user: input.user },
777
+ headers: input.headers,
778
+ clients,
779
+ mapError: (error) => codedErrorBody(error),
780
+ }),
781
+ },
782
+ );
783
+ await sendRuntimeResponse(reply, response);
784
+ return;
785
+ }
786
+
787
+ if (isProjectOAuthRequestPath(pathname)) {
788
+ const response = await handleProjectOAuth(
789
+ {
790
+ method: request.method,
791
+ pathname,
792
+ query: (request.query ?? {}) as Record<string, unknown>,
793
+ body: request.body,
794
+ headers: request.headers as Record<string, string | string[] | undefined>,
795
+ },
796
+ {
797
+ db: runtime.pool,
798
+ projectName: options.projectName ?? "robodev dev",
799
+ origin: options.listenOrigin,
800
+ secret: devOAuthSecret(options),
801
+ settings: devOAuthSettings(options),
802
+ signIn: (input) => localPasswordSignIn(runtime.pool, input),
803
+ signUp: (input) => localPasswordSignUp(runtime.pool, input),
804
+ verifyAppAccessToken: async (token) => {
805
+ try {
806
+ return await verifyProjectUserToken(token, options.projectId, options.jwtSecret);
807
+ } catch {
808
+ return null;
809
+ }
810
+ },
811
+ revokeAppRefreshToken: (token) => revokeRefresh(runtime.pool, token),
812
+ clientIp: request.ip,
813
+ },
814
+ );
815
+ if (response) {
816
+ await sendRuntimeResponse(reply, response);
817
+ return;
818
+ }
819
+ }
820
+
634
821
  if (isLocalAuthPath(pathname)) {
635
822
  const result = await handleLocalAuth(
636
823
  {
package/src/dev.ts CHANGED
@@ -36,7 +36,7 @@ import { externalDevPostgres, startDevPostgres, type DevPostgres } from "./dev-p
36
36
  import { starbaseUrl } from "./config.js";
37
37
  import { devRouteClients } from "./dev-clients.js";
38
38
  import { createDevInspect } from "./dev-inspect.js";
39
- import { buildDevOpenApi, startDevServer, type DevRuntime } from "./dev-server.js";
39
+ import { buildDevOpenApi, devMcpUrl, startDevServer, type DevRuntime } from "./dev-server.js";
40
40
 
41
41
  export type DevArgs = {
42
42
  port: number;
@@ -162,6 +162,7 @@ export async function main(args: string[]): Promise<void> {
162
162
  await mkdir(storageDir, { recursive: true });
163
163
 
164
164
  let current: DevRuntime | null = null;
165
+ let projectName = "robodev dev";
165
166
  let lastSchemaSource = "";
166
167
  let closing = false;
167
168
 
@@ -266,8 +267,10 @@ export async function main(args: string[]): Promise<void> {
266
267
  routes: loaded.routes,
267
268
  jobs: loaded.jobs,
268
269
  sockets: loaded.sockets,
269
- openapi: buildDevOpenApi(loaded.database.name, loaded.routes),
270
+ mcpTools: loaded.mcpTools,
271
+ openapi: buildDevOpenApi(loaded.database.name, loaded.routes, loaded.mcpTools),
270
272
  };
273
+ projectName = loaded.database.name;
271
274
  setProjectSocketNames(
272
275
  DEV_PROJECT_ID,
273
276
  loaded.sockets.map((socket) => socket.name),
@@ -287,6 +290,9 @@ export async function main(args: string[]): Promise<void> {
287
290
  for (const socket of loaded.sockets) {
288
291
  log(` WS ${socket.path}`);
289
292
  }
293
+ for (const tool of loaded.mcpTools) {
294
+ log(` MCP ${tool.name}`);
295
+ }
290
296
  if (previous) {
291
297
  await previous.pool.end().catch(() => undefined);
292
298
  }
@@ -316,6 +322,7 @@ export async function main(args: string[]): Promise<void> {
316
322
  googleClientSecret: env.GOOGLE_CLIENT_SECRET,
317
323
  storageDir,
318
324
  projectRoot: project.root,
325
+ projectName,
319
326
  },
320
327
  () => current,
321
328
  );
@@ -340,6 +347,9 @@ export async function main(args: string[]): Promise<void> {
340
347
  console.log(`API ${apiUrl}`);
341
348
  console.log(`Swagger ${apiUrl}/_robodev/docs`);
342
349
  console.log(`Dashboard ${apiUrl}/_robodev`);
350
+ if ((current as DevRuntime | null)?.mcpTools.length) {
351
+ console.log(`MCP ${devMcpUrl(apiUrl)}`);
352
+ }
343
353
  console.log(
344
354
  `Project ${relative(process.cwd(), project.backendDir) || "."} (${project.layout})`,
345
355
  );
@@ -9,10 +9,10 @@
9
9
  "rules:gen": "rulesync generate"
10
10
  },
11
11
  "dependencies": {
12
- "@robodev-ai/sdk": "^0.15.1"
12
+ "@robodev-ai/sdk": "^0.16.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.51.1",
15
+ "robodev": "^0.52.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -8,10 +8,10 @@
8
8
  "deploy": "robodev deploy"
9
9
  },
10
10
  "dependencies": {
11
- "@robodev-ai/sdk": "^0.15.1"
11
+ "@robodev-ai/sdk": "^0.16.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.51.1",
14
+ "robodev": "^0.52.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -10,13 +10,13 @@
10
10
  "build": "vite build"
11
11
  },
12
12
  "dependencies": {
13
- "@robodev-ai/sdk": "^0.15.1",
13
+ "@robodev-ai/sdk": "^0.16.0",
14
14
  "@robodev-ai/client": "^0.5.0",
15
15
  "react": "^18.3.1",
16
16
  "react-dom": "^18.3.1"
17
17
  },
18
18
  "devDependencies": {
19
- "robodev": "^0.51.1",
19
+ "robodev": "^0.52.0",
20
20
  "typescript": "^5.9.2",
21
21
  "vite": "^6.0.0"
22
22
  }
@@ -9,10 +9,10 @@
9
9
  "rules:gen": "rulesync generate"
10
10
  },
11
11
  "dependencies": {
12
- "@robodev-ai/sdk": "^0.15.1"
12
+ "@robodev-ai/sdk": "^0.16.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.51.1",
15
+ "robodev": "^0.52.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -9,10 +9,10 @@
9
9
  "rules:gen": "rulesync generate"
10
10
  },
11
11
  "dependencies": {
12
- "@robodev-ai/sdk": "^0.15.1"
12
+ "@robodev-ai/sdk": "^0.16.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.51.1",
15
+ "robodev": "^0.52.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -11,7 +11,7 @@
11
11
  "generate:api": "orval"
12
12
  },
13
13
  "dependencies": {
14
- "@robodev-ai/sdk": "^0.15.1",
14
+ "@robodev-ai/sdk": "^0.16.0",
15
15
  "@robodev-ai/client": "^0.5.0",
16
16
  "react": "^18.3.1",
17
17
  "react-dom": "^18.3.1",
@@ -21,7 +21,7 @@
21
21
  "@types/react": "^18.3.12",
22
22
  "@types/react-dom": "^18.3.1",
23
23
  "orval": "^7.11.2",
24
- "robodev": "^0.51.1",
24
+ "robodev": "^0.52.0",
25
25
  "typescript": "^5.9.2",
26
26
  "vite": "^6.0.0"
27
27
  }
@@ -9,10 +9,10 @@
9
9
  "rules:gen": "rulesync generate"
10
10
  },
11
11
  "dependencies": {
12
- "@robodev-ai/sdk": "^0.15.1"
12
+ "@robodev-ai/sdk": "^0.16.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.51.1",
15
+ "robodev": "^0.52.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }