@softr/sdk 0.0.47-alpha.0 → 0.0.47

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/dist/mcp.js ADDED
@@ -0,0 +1,580 @@
1
+ import { fetch } from "./fetch.js";
2
+ import { assert } from "./assertions.js";
3
+ import { sessionConfig } from "./auth/config.js";
4
+ import { readSessionToken, withSessionToken } from "./auth/session.js";
5
+ import { makePlatform, platformApiUrl } from "./auth/platform.js";
6
+ import { isDatasourceAction } from "./action.js";
7
+ import { ds } from "./datasource/datasource.js";
8
+ import { createMiddleware } from "@tanstack/react-start";
9
+ import { z } from "zod";
10
+
11
+ //#region src/mcp.ts
12
+ const MCP_PATH = "/_softr/mcp";
13
+ const AUTHORIZE_PATH = "/_softr/oauth/authorize";
14
+ const TOKEN_PATH = "/_softr/oauth/token";
15
+ const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
16
+ const AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server";
17
+ /** What the platform grants. Advertised here so a client asks for the same. */
18
+ const SCOPES = ["mcp:read", "mcp:write"];
19
+ /** What a client that named no scope gets. Matches the platform's own default. */
20
+ const DEFAULT_SCOPE = "mcp:read";
21
+ const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26"];
22
+ /**
23
+ * Serves the MCP endpoint and its OAuth flow from the app's own origin.
24
+ *
25
+ * Register it in the app's `src/start.ts`, ahead of `softrAuthMiddleware`: it
26
+ * answers its own paths and resolves the caller from the bearer token, so it
27
+ * must not sit behind the session lookup that middleware does on every
28
+ * request. Everything else passes straight through.
29
+ *
30
+ * @example
31
+ * import { publishedPosts, addCafe } from './actions'
32
+ *
33
+ * export const startInstance = createStart(() => ({
34
+ * requestMiddleware: [
35
+ * softrMcpMiddleware({ actions: [publishedPosts, addCafe] }),
36
+ * softrAuthMiddleware(),
37
+ * ],
38
+ * }))
39
+ */
40
+ function softrMcpMiddleware(options = {}) {
41
+ const loginPath = options.loginPath ?? "/login";
42
+ assert(loginPath.startsWith("/") && new URL(loginPath, "https://app.invalid").origin === "https://app.invalid", `softrMcpMiddleware's loginPath must be a path on this app, got "${loginPath}"`);
43
+ const actions = /* @__PURE__ */ new Map();
44
+ for (const action of options.actions ?? []) {
45
+ assert(isDatasourceAction(action), "softrMcpMiddleware takes actions built by defineQuery or defineMutation");
46
+ const value = action;
47
+ assert(!actions.has(value.id), `two actions share the id "${value.id}"`);
48
+ actions.set(value.id, value);
49
+ }
50
+ return createMiddleware({ type: "request" }).server(async ({ next, request }) => {
51
+ const url = new URL(request.url);
52
+ const origin = url.origin;
53
+ switch (url.pathname) {
54
+ case PROTECTED_RESOURCE_PATH: return json({
55
+ resource: `${origin}${MCP_PATH}`,
56
+ authorization_servers: [origin],
57
+ bearer_methods_supported: ["header"],
58
+ scopes_supported: SCOPES
59
+ });
60
+ case AUTHORIZATION_SERVER_PATH: return json({
61
+ issuer: origin,
62
+ authorization_endpoint: `${origin}${AUTHORIZE_PATH}`,
63
+ token_endpoint: `${origin}${TOKEN_PATH}`,
64
+ response_types_supported: ["code"],
65
+ grant_types_supported: ["authorization_code", "refresh_token"],
66
+ code_challenge_methods_supported: ["S256"],
67
+ token_endpoint_auth_methods_supported: ["none", "client_secret_basic"],
68
+ client_id_metadata_document_supported: true,
69
+ scopes_supported: SCOPES
70
+ });
71
+ case AUTHORIZE_PATH: return await authorize(request, loginPath);
72
+ case TOKEN_PATH: return await issueToken(request);
73
+ case MCP_PATH: return await rpc(request, origin, actions);
74
+ default: return await next();
75
+ }
76
+ });
77
+ }
78
+ const authorizeQuerySchema = z.object({
79
+ client_id: z.string().min(1),
80
+ redirect_uri: z.string().url(),
81
+ response_type: z.literal("code"),
82
+ code_challenge: z.string().min(1),
83
+ code_challenge_method: z.literal("S256"),
84
+ state: z.string().optional(),
85
+ scope: z.string().optional(),
86
+ resource: z.string().optional()
87
+ });
88
+ /**
89
+ * The Allow page, and the approval itself.
90
+ *
91
+ * We know who the visitor is from the app's session cookie — readable only
92
+ * because this runs on the app's origin. The client and the code are the
93
+ * platform's, so we ask it for both.
94
+ */
95
+ async function authorize(request, loginPath) {
96
+ const url = new URL(request.url);
97
+ const query = authorizeQuerySchema.safeParse(Object.fromEntries(url.searchParams));
98
+ if (!query.success) return html(400, page("This request is not valid", "The app that sent you here left something out. Close this window and try connecting again."));
99
+ if (request.method !== "GET" && request.method !== "POST") return methodNotAllowed("GET, POST");
100
+ const sessionToken = readSessionToken();
101
+ const user = sessionToken === null ? null : await resolveUser(sessionToken);
102
+ if (user === null || sessionToken === null) return html(200, signInPage(url, loginPath));
103
+ const client = await platformConsent(query.data);
104
+ if (!client.ok) return html(400, page("We can't connect this right now", escapeHtml(client.message)));
105
+ if (request.method === "GET") return html(200, consentPage(user, url, client.name));
106
+ const form = await request.formData();
107
+ const decision = await platformApprove(sessionToken, query.data, form.get("decision") === "allow");
108
+ if (!decision.ok) return html(502, page("We could not finish this", decision.message));
109
+ return redirect(decision.redirectUri);
110
+ }
111
+ /**
112
+ * Passes the token request straight through. The code and refresh token are the
113
+ * platform's, so only it can redeem them. The client's credentials ride along
114
+ * in the Authorization header untouched.
115
+ */
116
+ async function issueToken(request) {
117
+ if (request.method !== "POST") return methodNotAllowed("POST");
118
+ const authorization = request.headers.get("authorization");
119
+ const response = await platformFetch("/v1/oauth/token", {
120
+ method: "POST",
121
+ headers: {
122
+ "content-type": "application/x-www-form-urlencoded",
123
+ ...authorization === null ? {} : { authorization }
124
+ },
125
+ body: await request.text()
126
+ });
127
+ return new Response(await response.text(), {
128
+ status: response.status,
129
+ headers: {
130
+ "content-type": "application/json",
131
+ "access-control-allow-origin": "*",
132
+ "cache-control": "no-store"
133
+ }
134
+ });
135
+ }
136
+ /**
137
+ * Tells the platform which app this is about. It normally works that out from
138
+ * the domain, and none of the calls below arrive on the app's domain.
139
+ */
140
+ const APPLICATION_ID_HEADER = "Softr-Application-ID";
141
+ /**
142
+ * An unreachable platform comes back as a 503, not a thrown error, so callers
143
+ * can handle it with the `response.ok` check they already do. If it threw, the
144
+ * MCP endpoint would answer 500 where the client needs a refusal it can act on.
145
+ */
146
+ async function platformFetch(path, init) {
147
+ const { applicationId } = sessionConfig();
148
+ try {
149
+ return await fetch(`${await platformApiUrl()}${path}`, {
150
+ method: init.method,
151
+ headers: {
152
+ accept: "application/json",
153
+ [APPLICATION_ID_HEADER]: applicationId,
154
+ ...init.headers
155
+ },
156
+ body: init.body
157
+ });
158
+ } catch {
159
+ return json({ message: "Softr could not be reached. Try again in a moment." }, 503);
160
+ }
161
+ }
162
+ /** The name to show on the Allow page, or why there's nothing to approve. */
163
+ async function platformConsent(query) {
164
+ const response = await platformFetch(`/v1/oauth/authorize?${new URLSearchParams({
165
+ client_id: query.client_id,
166
+ redirect_uri: query.redirect_uri,
167
+ response_type: query.response_type,
168
+ code_challenge: query.code_challenge,
169
+ code_challenge_method: query.code_challenge_method,
170
+ state: query.state ?? "",
171
+ scope: query.scope ?? DEFAULT_SCOPE
172
+ })}`, { method: "GET" });
173
+ const body = await response.json().catch(() => null);
174
+ if (!response.ok) {
175
+ const refusal = z.object({ message: z.string() }).safeParse(body);
176
+ return {
177
+ ok: false,
178
+ message: refusal.success ? refusal.data.message : "The assistant asking for access is not set up for this app."
179
+ };
180
+ }
181
+ const parsed = z.object({ client_name: z.string().nullish() }).safeParse(body);
182
+ return {
183
+ ok: true,
184
+ name: parsed.success && parsed.data.client_name ? parsed.data.client_name : "An AI assistant"
185
+ };
186
+ }
187
+ /**
188
+ * Records the answer and returns where to send the browser next — with a code
189
+ * if they allowed it, an error if they didn't. The session token goes in the
190
+ * header. The platform reads a cookie from its own consent page, but this
191
+ * isn't that page.
192
+ */
193
+ async function platformApprove(sessionToken, query, approved) {
194
+ const response = await platformFetch("/v1/oauth/authorize", {
195
+ method: "POST",
196
+ headers: {
197
+ "content-type": "application/json",
198
+ authorization: `Bearer ${sessionToken}`
199
+ },
200
+ body: JSON.stringify({
201
+ client_id: query.client_id,
202
+ redirect_uri: query.redirect_uri,
203
+ state: query.state ?? "",
204
+ code_challenge: query.code_challenge,
205
+ code_challenge_method: query.code_challenge_method,
206
+ scope: query.scope ?? DEFAULT_SCOPE,
207
+ approved
208
+ })
209
+ });
210
+ const parsed = z.object({ redirect_uri: z.string().url() }).safeParse(await response.json().catch(() => null));
211
+ if (!response.ok || !parsed.success) return {
212
+ ok: false,
213
+ message: "The app could not record your answer. Close this window and try connecting again."
214
+ };
215
+ return {
216
+ ok: true,
217
+ redirectUri: parsed.data.redirect_uri
218
+ };
219
+ }
220
+ async function platformSession(accessToken) {
221
+ const response = await platformFetch("/v1/mcp/session", {
222
+ method: "POST",
223
+ headers: { authorization: `Bearer ${accessToken}` }
224
+ });
225
+ if (!response.ok) return {
226
+ ok: false,
227
+ refused: response.status === 401 || response.status === 403
228
+ };
229
+ const parsed = z.object({ jwt: z.string() }).safeParse(await response.json().catch(() => null));
230
+ return parsed.success ? {
231
+ ok: true,
232
+ sessionToken: parsed.data.jwt
233
+ } : {
234
+ ok: false,
235
+ refused: false
236
+ };
237
+ }
238
+ const WHOAMI = {
239
+ name: "whoami",
240
+ title: "Who am I",
241
+ description: "Returns the Softr user this connection is authenticated as: their name, email and user group ids. Use it to confirm which account the app sees.",
242
+ inputSchema: {
243
+ type: "object",
244
+ properties: {},
245
+ additionalProperties: false
246
+ }
247
+ };
248
+ /** How many records a read returns when the caller does not say. */
249
+ const DEFAULT_LIMIT = 20;
250
+ const MAX_LIMIT = 100;
251
+ /**
252
+ * One tool per action the app chose to expose.
253
+ *
254
+ * The app passes its actions in rather than the SDK discovering them, so that
255
+ * being reachable over MCP is something an app opts into per action. An app
256
+ * that only ever reads one datasource exposes one read, even though its
257
+ * credentials would allow more.
258
+ */
259
+ function describeTool(action) {
260
+ const { operation, fields } = action.resource;
261
+ const names = Object.keys(fields ?? {});
262
+ const fieldProps = Object.fromEntries(names.map((name) => [name, { description: `The "${name}" field.` }]));
263
+ switch (operation) {
264
+ case "read": return {
265
+ name: action.id,
266
+ description: `Read records from "${action.id}". Returns the fields ${names.join(", ")}. Only records the signed-in user may see come back.`,
267
+ inputSchema: {
268
+ type: "object",
269
+ properties: {
270
+ limit: {
271
+ type: "integer",
272
+ minimum: 1,
273
+ maximum: MAX_LIMIT,
274
+ description: `How many records to return. Defaults to ${DEFAULT_LIMIT}.`
275
+ },
276
+ offset: {
277
+ type: "integer",
278
+ minimum: 0,
279
+ description: "Where the page starts. Use nextOffset from a previous call."
280
+ }
281
+ },
282
+ additionalProperties: false
283
+ }
284
+ };
285
+ case "create": return {
286
+ name: action.id,
287
+ description: `Create a record with "${action.id}". Writable fields: ${names.join(", ")}.`,
288
+ inputSchema: {
289
+ type: "object",
290
+ properties: fieldProps,
291
+ additionalProperties: false
292
+ }
293
+ };
294
+ case "update": return {
295
+ name: action.id,
296
+ description: `Update a record with "${action.id}". Writable fields: ${names.join(", ")}.`,
297
+ inputSchema: {
298
+ type: "object",
299
+ properties: {
300
+ id: {
301
+ type: "string",
302
+ description: "The id of the record to update."
303
+ },
304
+ ...fieldProps
305
+ },
306
+ required: ["id"],
307
+ additionalProperties: false
308
+ }
309
+ };
310
+ case "delete": return {
311
+ name: action.id,
312
+ description: `Delete a record with "${action.id}".`,
313
+ inputSchema: {
314
+ type: "object",
315
+ properties: { id: {
316
+ type: "string",
317
+ description: "The id of the record to delete."
318
+ } },
319
+ required: ["id"],
320
+ additionalProperties: false
321
+ }
322
+ };
323
+ }
324
+ }
325
+ /**
326
+ * Runs one action as the visitor the access token belongs to.
327
+ *
328
+ * Every call goes through `ds`, so the platform authorizes it against that
329
+ * visitor's user groups exactly as it would a click in the app. Nothing here
330
+ * decides what is allowed.
331
+ */
332
+ async function runTool(action, args) {
333
+ const typed = action;
334
+ switch (action.resource.operation) {
335
+ case "read": {
336
+ const limit = clampLimit(args.limit);
337
+ const offset = clampOffset(args.offset);
338
+ const result$1 = await ds.list(typed).limit(limit).offset(offset);
339
+ return {
340
+ records: result$1.records,
341
+ nextOffset: result$1.nextOffset
342
+ };
343
+ }
344
+ case "create": return await ds.create(typed, { data: writableFields(action, args) });
345
+ case "update": {
346
+ const { id,...data } = args;
347
+ assert(typeof id === "string" && id !== "", "update needs a record id");
348
+ return await ds.update(typed, {
349
+ id,
350
+ data: writableFields(action, data)
351
+ });
352
+ }
353
+ case "delete": {
354
+ const { id } = args;
355
+ assert(typeof id === "string" && id !== "", "delete needs a record id");
356
+ await ds.delete(typed, { id });
357
+ return { deleted: id };
358
+ }
359
+ }
360
+ }
361
+ /**
362
+ * Only the fields the action declared.
363
+ *
364
+ * `additionalProperties: false` in the tool schema is a hint to the model, not
365
+ * a check — a client can send whatever it likes. An unlisted key reaches the
366
+ * datasource as a raw field id, because `dealiasData` passes an unknown alias
367
+ * straight through. So the action's field list gets enforced here, or it isn't
368
+ * a boundary at all.
369
+ */
370
+ function writableFields(action, args) {
371
+ const allowed = action.resource.fields ?? {};
372
+ return Object.fromEntries(Object.entries(args).filter(([name]) => name in allowed));
373
+ }
374
+ function clampLimit(raw) {
375
+ if (typeof raw !== "number" || !Number.isInteger(raw)) return DEFAULT_LIMIT;
376
+ return Math.min(Math.max(raw, 1), MAX_LIMIT);
377
+ }
378
+ function clampOffset(raw) {
379
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) return null;
380
+ return raw;
381
+ }
382
+ async function rpc(request, origin, actions) {
383
+ if (request.method !== "POST") return methodNotAllowed("POST");
384
+ const token = bearerToken(request);
385
+ if (token === null) return unauthorized(origin);
386
+ const session = await platformSession(token);
387
+ if (!session.ok) return session.refused ? unauthorized(origin) : unavailable();
388
+ const { sessionToken } = session;
389
+ const body = await readJson(request);
390
+ const message = z.object({
391
+ id: z.union([z.string(), z.number()]).optional(),
392
+ method: z.string(),
393
+ params: z.record(z.string(), z.unknown()).optional()
394
+ }).safeParse(body);
395
+ if (!message.success) return json({
396
+ jsonrpc: "2.0",
397
+ id: null,
398
+ error: {
399
+ code: -32700,
400
+ message: "Parse error"
401
+ }
402
+ });
403
+ const { id, method, params } = message.data;
404
+ if (id === void 0) return new Response(null, { status: 202 });
405
+ switch (method) {
406
+ case "initialize": {
407
+ const requested = params?.protocolVersion;
408
+ return result(id, {
409
+ protocolVersion: typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : SUPPORTED_PROTOCOL_VERSIONS[0],
410
+ capabilities: { tools: { listChanged: false } },
411
+ serverInfo: {
412
+ name: "softr-app",
413
+ version: "0.1.0"
414
+ }
415
+ });
416
+ }
417
+ case "tools/list": return result(id, { tools: [WHOAMI, ...[...actions.values()].map(describeTool)] });
418
+ case "tools/call": {
419
+ const name = params?.name;
420
+ const args = typeof params?.arguments === "object" && params.arguments !== null ? params.arguments : {};
421
+ if (name === WHOAMI.name) {
422
+ const user = await resolveUser(sessionToken);
423
+ if (user === null) return unauthorized(origin);
424
+ return result(id, toolResult(user));
425
+ }
426
+ const action = typeof name === "string" ? actions.get(name) : void 0;
427
+ if (action === void 0) return failure(id, -32602, `Unknown tool: ${String(name)}`);
428
+ try {
429
+ return result(id, toolResult(await withSessionToken(sessionToken, () => runTool(action, args))));
430
+ } catch (error) {
431
+ return result(id, {
432
+ isError: true,
433
+ content: [{
434
+ type: "text",
435
+ text: error instanceof Error ? error.message : String(error)
436
+ }]
437
+ });
438
+ }
439
+ }
440
+ default: return failure(id, -32601, `Method not found: ${method}`);
441
+ }
442
+ }
443
+ function toolResult(output) {
444
+ return {
445
+ content: [{
446
+ type: "text",
447
+ text: JSON.stringify(output, null, 2)
448
+ }],
449
+ structuredContent: output
450
+ };
451
+ }
452
+ function result(id, payload) {
453
+ return json({
454
+ jsonrpc: "2.0",
455
+ id,
456
+ result: payload
457
+ });
458
+ }
459
+ function failure(id, code, message) {
460
+ return json({
461
+ jsonrpc: "2.0",
462
+ id,
463
+ error: {
464
+ code,
465
+ message
466
+ }
467
+ });
468
+ }
469
+ function unauthorized(origin) {
470
+ return new Response(JSON.stringify({ error: "invalid_token" }), {
471
+ status: 401,
472
+ headers: {
473
+ "content-type": "application/json",
474
+ "www-authenticate": `Bearer resource_metadata="${origin}${PROTECTED_RESOURCE_PATH}", scope="${SCOPES.join(" ")}"`
475
+ }
476
+ });
477
+ }
478
+ /** The platform is down, not the token. 503 so the client retries rather than
479
+ * throwing its token away. */
480
+ function unavailable() {
481
+ return json({ error: "temporarily_unavailable" }, 503);
482
+ }
483
+ function bearerToken(request) {
484
+ const header = request.headers.get("authorization");
485
+ if (header === null) return null;
486
+ const [scheme, value] = header.split(" ");
487
+ return scheme?.toLowerCase() === "bearer" && value ? value : null;
488
+ }
489
+ /**
490
+ * The visitor a session token belongs to, or `null` when the platform refuses
491
+ * it — expired, revoked, or issued for another application.
492
+ */
493
+ async function resolveUser(sessionToken) {
494
+ const { applicationId } = sessionConfig();
495
+ const result$1 = await makePlatform(applicationId).session(sessionToken);
496
+ return result$1.ok ? result$1.data.user : null;
497
+ }
498
+ async function readJson(request) {
499
+ try {
500
+ return await request.json();
501
+ } catch {
502
+ return null;
503
+ }
504
+ }
505
+ function json(payload, status = 200) {
506
+ return new Response(JSON.stringify(payload), {
507
+ status,
508
+ headers: {
509
+ "content-type": "application/json",
510
+ "access-control-allow-origin": "*",
511
+ "cache-control": "no-store"
512
+ }
513
+ });
514
+ }
515
+ function methodNotAllowed(allowed) {
516
+ return new Response(null, {
517
+ status: 405,
518
+ headers: { allow: allowed }
519
+ });
520
+ }
521
+ function redirect(location) {
522
+ return new Response(null, {
523
+ status: 302,
524
+ headers: { location }
525
+ });
526
+ }
527
+ function html(status, body) {
528
+ return new Response(body, {
529
+ status,
530
+ headers: {
531
+ "content-type": "text/html; charset=utf-8",
532
+ "cache-control": "no-store"
533
+ }
534
+ });
535
+ }
536
+ function consentPage(user, url, clientName) {
537
+ return page("Connect to this app?", `<strong>${escapeHtml(clientName)}</strong> is asking to use this app as <strong>${escapeHtml(user.fullName || user.email)}</strong>. It will see only what you can see.`, `<form method="post" action="${escapeHtml(url.pathname + url.search)}">
538
+ <button type="submit" name="decision" value="allow" class="primary">Allow</button>
539
+ <button type="submit" name="decision" value="deny">Cancel</button>
540
+ </form>`);
541
+ }
542
+ function signInPage(url, loginPath) {
543
+ const back = encodeURIComponent(url.pathname + url.search);
544
+ return page("Sign in first", "You need to be signed in to this app before you can connect an assistant to it.", `<a class="primary" href="${escapeHtml(loginPath)}?redirect=${back}">Sign in</a>`);
545
+ }
546
+ function page(title, body, actions = "") {
547
+ return `<!doctype html>
548
+ <html lang="en">
549
+ <head>
550
+ <meta charset="utf-8">
551
+ <meta name="viewport" content="width=device-width, initial-scale=1">
552
+ <title>${escapeHtml(title)}</title>
553
+ <style>
554
+ :root { color-scheme: light dark; }
555
+ body {
556
+ margin: 0; min-height: 100vh; display: grid; place-items: center;
557
+ font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
558
+ background: Canvas; color: CanvasText; padding: 24px;
559
+ }
560
+ main { max-width: 26rem; }
561
+ h1 { font-size: 1.35rem; margin: 0 0 .6rem; }
562
+ p { margin: 0 0 1.5rem; opacity: .8; }
563
+ form { display: flex; gap: .6rem; flex-wrap: wrap; }
564
+ button, a.primary {
565
+ font: inherit; padding: .55rem 1.1rem; border-radius: .4rem; cursor: pointer;
566
+ border: 1px solid currentColor; background: transparent; color: inherit;
567
+ text-decoration: none; display: inline-block;
568
+ }
569
+ .primary { background: CanvasText; color: Canvas; border-color: CanvasText; }
570
+ </style>
571
+ </head>
572
+ <body><main><h1>${escapeHtml(title)}</h1><p>${body}</p>${actions}</main></body>
573
+ </html>`;
574
+ }
575
+ function escapeHtml(value) {
576
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
577
+ }
578
+
579
+ //#endregion
580
+ export { softrMcpMiddleware };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.47-alpha.0";
2
+ var version = "0.0.46";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/dist/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { upload } from "./core/file-upload.js";
2
- import { auth } from "./auth/server.js";
3
2
  import { ds } from "./datasource/datasource.js";
3
+ import { auth } from "./auth/server.js";
4
4
  import { workflow } from "./core/workflow.js";
5
5
  import { integration } from "./core/integration.js";
6
6
  import "@tanstack/react-start/server-only";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.47-alpha.0",
3
+ "version": "0.0.47",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: core
13
13
  library: softr-sdk
14
- library_version: 0.0.47-alpha.0
14
+ library_version: 0.0.47
15
15
  ---
16
16
 
17
17
  # Softr SDK core
@@ -20,7 +20,7 @@ metadata:
20
20
 
21
21
  > **CRITICAL**: The SDK is split across four entrypoints and the split is enforced. `ds` and `auth` exist only on `@softr/sdk/server` and only run inside a `createServerFn` handler. Importing them anywhere else fails the build.
22
22
  > **CRITICAL**: Every route file must declare its audience with `defineVisibility` and guard itself with that rule in `beforeLoad`. There is no default and nothing is inherited from a sibling.
23
- > **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register the middleware and resolve the session. Do not add a second registration.
23
+ > **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register `softrAuthMiddleware` and resolve the session. Do not add a second registration of it. `softrMcpMiddleware` is the one thing an app adds to `src/start.ts` itself — load the `softr-sdk/mcp` sub-skill before doing so.
24
24
  > **CRITICAL**: `workflow.trigger` lives on `@softr/sdk/server` and only runs inside a `createServerFn` handler. Use `defineWorkflow` from `@softr/sdk` to declare the action, then pass it to `workflow.trigger`. Load the `softr-sdk/workflows` sub-skill for the full pattern.
25
25
 
26
26
  Each sub-skill stands alone for its task and repeats the boundary above, so most work needs **one** sub-skill and not this file. Load this one when you are unsure which sub-skill applies, or when the wiring below is what you are touching. Add a second sub-skill only when the work genuinely crosses that boundary: a page that lists records behind a group restriction needs `record-queries` plus `routes-and-visibility`.
@@ -29,7 +29,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
29
29
 
30
30
  | Import | Runs | Holds |
31
31
  | --- | --- | --- |
32
- | `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `SoftrPwaHead`, `usePwaInstall`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
32
+ | `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
33
33
  | `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration` |
34
34
  | `@softr/sdk/client` | Browser only | Nothing yet, reserved for browser-only utilities |
35
35
  | `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
@@ -47,7 +47,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
47
47
  | Trigger a Softr workflow | [workflows/SKILL.md](./workflows/SKILL.md) |
48
48
  | Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
49
49
  | Call a connected third-party API (Slack, Stripe, Gmail, …) | [integrations/SKILL.md](./integrations/SKILL.md) |
50
- | Let visitors install the app on their home screen | [pwa/SKILL.md](./pwa/SKILL.md) |
50
+ | Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
51
51
 
52
52
  ## Quick Decision Tree
53
53
 
@@ -79,8 +79,8 @@ Wiring a login, signup, or sign-out?
79
79
  Calling a connected third-party API (Slack, Stripe, Gmail, Google Calendar, …)?
80
80
  load softr-sdk/integrations
81
81
 
82
- Adding an install button, or making the app installable?
83
- load softr-sdk/pwa
82
+ Opening the app to an AI assistant over MCP?
83
+ load softr-sdk/mcp
84
84
 
85
85
  Writing a server function, loader, or middleware in general?
86
86
  not a Softr concern. Use the TanStack Start skills.
@@ -88,7 +88,7 @@ Writing a server function, loader, or middleware in general?
88
88
 
89
89
  ## The wiring
90
90
 
91
- Already present in the app. Read it to understand where the visitor comes from. Do not recreate it.
91
+ Already present in the app. Read it to understand where the visitor comes from. Do not recreate it. `softrMcpMiddleware` is the exception: it is not wired by default and is added here when the app opens itself to AI assistants.
92
92
 
93
93
  ```ts
94
94
  // src/start.ts: resolves the visitor once per request
@@ -16,7 +16,7 @@ description: >-
16
16
  metadata:
17
17
  type: sub-skill
18
18
  library: softr-sdk
19
- library_version: 0.0.47-alpha.0
19
+ library_version: 0.0.47
20
20
  ---
21
21
 
22
22
  **Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.47-alpha.0
15
+ library_version: 0.0.47
16
16
  ---
17
17
 
18
18
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.47-alpha.0
12
+ library_version: 0.0.47
13
13
  ---
14
14
 
15
15
  # The current user