@softr/sdk 0.0.46-alpha.2 → 0.0.46
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/action.d.ts +14 -5
- package/dist/action.js +4 -2
- package/dist/auth/session.js +1 -15
- package/dist/core/integration.d.ts +3 -2
- package/dist/core/integration.js +13 -6
- package/dist/datasource/record.js +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/package.js +1 -1
- package/dist/server.js +1 -1
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +3 -7
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
- package/skills/softr-sdk/integrations/SKILL.md +54 -19
- package/skills/softr-sdk/record-mutations/SKILL.md +3 -1
- package/skills/softr-sdk/record-queries/SKILL.md +1 -1
- package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +6 -5
- package/skills/softr-sdk/workflows/SKILL.md +1 -1
- package/dist/mcp.d.ts +0 -36
- package/dist/mcp.js +0 -581
- package/skills/softr-sdk/mcp/SKILL.md +0 -86
package/dist/action.d.ts
CHANGED
|
@@ -198,9 +198,12 @@ type WorkflowDefinition<TId extends string> = {
|
|
|
198
198
|
* await workflow.trigger(notifyTeam, { subject: "Hello" });
|
|
199
199
|
*/
|
|
200
200
|
declare function defineWorkflow<const TId extends string>(definition: WorkflowDefinition<TId>): WorkflowAction<TId>;
|
|
201
|
+
type IntegrationMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
201
202
|
type IntegrationResource = {
|
|
202
203
|
type: "INTEGRATION";
|
|
203
204
|
integrationId: string;
|
|
205
|
+
method: IntegrationMethod;
|
|
206
|
+
url: string;
|
|
204
207
|
};
|
|
205
208
|
type IntegrationAction<TId extends string = string> = Action<IntegrationResource> & {
|
|
206
209
|
id: TId;
|
|
@@ -208,22 +211,28 @@ type IntegrationAction<TId extends string = string> = Action<IntegrationResource
|
|
|
208
211
|
type IntegrationDefinition<TId extends string> = {
|
|
209
212
|
id: TId;
|
|
210
213
|
integrationId: string;
|
|
214
|
+
method: IntegrationMethod;
|
|
215
|
+
url: string;
|
|
211
216
|
permissions: ActionPermissions;
|
|
212
217
|
};
|
|
213
218
|
/**
|
|
214
|
-
* Declares an integration: which workspace integration to call
|
|
219
|
+
* Declares an integration action: which workspace integration to call, the one HTTP method and the
|
|
220
|
+
* endpoint URL it allows, and who may call it. The proxy denies a call whose method does not match,
|
|
221
|
+
* so declare one action per operation. Use a `{placeholder}` token for a dynamic path segment,
|
|
215
222
|
*
|
|
216
223
|
* Pass the returned object to `integration.createFetcher()` to get a bound fetcher.
|
|
217
224
|
*
|
|
218
225
|
* @example
|
|
219
|
-
* const
|
|
220
|
-
* id: "
|
|
226
|
+
* const listCalendarEvents = defineIntegration({
|
|
227
|
+
* id: "listCalendarEvents",
|
|
221
228
|
* integrationId: "25d60213-ef4f-4fa0-b094-a46931b2d120",
|
|
229
|
+
* method: "GET",
|
|
230
|
+
* url: "https://www.googleapis.com/calendar/v3/calendars/primary/events",
|
|
222
231
|
* permissions: { userGroup: "LOGGED_IN_USERS" },
|
|
223
232
|
* })
|
|
224
233
|
*
|
|
225
|
-
* const
|
|
226
|
-
* const res = await
|
|
234
|
+
* const listEvents = integration.createFetcher(listCalendarEvents)
|
|
235
|
+
* const res = await listEvents({ query: { timeMin: now, singleEvents: "true" } })
|
|
227
236
|
*/
|
|
228
237
|
declare function defineIntegration<const TId extends string>(definition: IntegrationDefinition<TId>): IntegrationAction<TId>;
|
|
229
238
|
//#endregion
|
package/dist/action.js
CHANGED
|
@@ -38,12 +38,14 @@ function defineWorkflow(definition) {
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
function defineIntegration(definition) {
|
|
41
|
-
const { id, integrationId, permissions } = definition;
|
|
41
|
+
const { id, integrationId, method, url, permissions } = definition;
|
|
42
42
|
return {
|
|
43
43
|
id,
|
|
44
44
|
resource: {
|
|
45
45
|
type: "INTEGRATION",
|
|
46
|
-
integrationId
|
|
46
|
+
integrationId,
|
|
47
|
+
method,
|
|
48
|
+
url
|
|
47
49
|
},
|
|
48
50
|
permissions
|
|
49
51
|
};
|
package/dist/auth/session.js
CHANGED
|
@@ -3,7 +3,6 @@ import { permissionsSchema } from "./permissions.js";
|
|
|
3
3
|
import { sessionConfig } from "./config.js";
|
|
4
4
|
import { userSchema } from "./user.js";
|
|
5
5
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
6
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
6
|
import { getRequestProtocol } from "@tanstack/react-start/server";
|
|
8
7
|
import { z } from "zod";
|
|
9
8
|
|
|
@@ -36,20 +35,8 @@ function sessionCookie(base) {
|
|
|
36
35
|
secure
|
|
37
36
|
};
|
|
38
37
|
}
|
|
39
|
-
/**
|
|
40
|
-
* Runs `fn` with `token` standing in for the request's session cookie, so that
|
|
41
|
-
* every datasource call inside it acts as that visitor.
|
|
42
|
-
*
|
|
43
|
-
* For requests that carry who they are somewhere other than the cookie: an MCP
|
|
44
|
-
* call authenticates with a bearer token and has no cookie at all.
|
|
45
|
-
*/
|
|
46
|
-
function withSessionToken(token, fn) {
|
|
47
|
-
return standInToken.run(token, fn);
|
|
48
|
-
}
|
|
49
38
|
/** The platform token this request carries, or `null`. */
|
|
50
39
|
function readSessionToken() {
|
|
51
|
-
const standIn = standInToken.getStore();
|
|
52
|
-
if (standIn !== void 0) return standIn;
|
|
53
40
|
const { name } = sessionCookie(SESSION_TOKEN);
|
|
54
41
|
return openEnvelope(readRequestCookies()[name], SESSION_TOKEN);
|
|
55
42
|
}
|
|
@@ -118,7 +105,6 @@ function clearSession() {
|
|
|
118
105
|
PENDING_AUTH
|
|
119
106
|
]);
|
|
120
107
|
}
|
|
121
|
-
const standInToken = new AsyncLocalStorage();
|
|
122
108
|
const sessionDataSchema = z.object({
|
|
123
109
|
user: userSchema.nullable(),
|
|
124
110
|
permissions: permissionsSchema,
|
|
@@ -210,4 +196,4 @@ function openEnvelope(raw, purpose) {
|
|
|
210
196
|
}
|
|
211
197
|
|
|
212
198
|
//#endregion
|
|
213
|
-
export { clearPendingAuth, clearSession, makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie, readPendingAuthToken, readSessionData, readSessionToken
|
|
199
|
+
export { clearPendingAuth, clearSession, makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie, readPendingAuthToken, readSessionData, readSessionToken };
|
|
@@ -2,12 +2,13 @@ import { IntegrationAction } from "../action.js";
|
|
|
2
2
|
|
|
3
3
|
//#region src/core/integration.d.ts
|
|
4
4
|
interface IntegrationFetchInit {
|
|
5
|
-
|
|
5
|
+
params?: Record<string, string>;
|
|
6
|
+
query?: Record<string, string>;
|
|
6
7
|
headers?: Record<string, string>;
|
|
7
8
|
body?: string;
|
|
8
9
|
signal?: AbortSignal;
|
|
9
10
|
}
|
|
10
|
-
declare function integrationCreateFetcher(action: IntegrationAction): (
|
|
11
|
+
declare function integrationCreateFetcher(action: IntegrationAction): (init?: IntegrationFetchInit) => Promise<Response>;
|
|
11
12
|
declare const integration: {
|
|
12
13
|
createFetcher: typeof integrationCreateFetcher;
|
|
13
14
|
};
|
package/dist/core/integration.js
CHANGED
|
@@ -3,15 +3,14 @@ import { readSessionToken } from "../auth/session.js";
|
|
|
3
3
|
import { dsUrl } from "../datasource/record.js";
|
|
4
4
|
|
|
5
5
|
//#region src/core/integration.ts
|
|
6
|
-
async function integrationFetch(action,
|
|
6
|
+
async function integrationFetch(action, { params, query, headers = {}, body, signal } = {}) {
|
|
7
7
|
if (signal?.aborted) throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
8
|
-
const upperMethod = method.toUpperCase();
|
|
9
8
|
const payload = {
|
|
10
|
-
method:
|
|
11
|
-
url,
|
|
9
|
+
method: action.resource.method,
|
|
10
|
+
url: buildUrl(action.resource.url, params, query),
|
|
12
11
|
headers
|
|
13
12
|
};
|
|
14
|
-
if (
|
|
13
|
+
if (body !== void 0) payload.body = body;
|
|
15
14
|
return fetch(`${dsUrl(action.id)}/proxy`, {
|
|
16
15
|
method: "POST",
|
|
17
16
|
headers: {
|
|
@@ -22,8 +21,16 @@ async function integrationFetch(action, url, { method = "GET", headers = {}, bod
|
|
|
22
21
|
signal
|
|
23
22
|
});
|
|
24
23
|
}
|
|
24
|
+
function buildUrl(template, params, query) {
|
|
25
|
+
let url = params === void 0 ? template : template.replace(/\{(\w+)\}/g, (_, key) => {
|
|
26
|
+
const value = params[key];
|
|
27
|
+
return value !== void 0 ? encodeURIComponent(value) : `{${key}}`;
|
|
28
|
+
});
|
|
29
|
+
if (query !== void 0 && Object.keys(query).length > 0) url += `?${new URLSearchParams(query).toString()}`;
|
|
30
|
+
return url;
|
|
31
|
+
}
|
|
25
32
|
function integrationCreateFetcher(action) {
|
|
26
|
-
return (
|
|
33
|
+
return (init) => integrationFetch(action, init);
|
|
27
34
|
}
|
|
28
35
|
const integration = { createFetcher: integrationCreateFetcher };
|
|
29
36
|
|
|
@@ -249,7 +249,7 @@ function resolveTarget(subject, operation) {
|
|
|
249
249
|
}
|
|
250
250
|
function aliasFields(fields, aliases) {
|
|
251
251
|
const result = {};
|
|
252
|
-
for (const [alias, fieldId] of Object.entries(aliases)) result[alias] = fields[fieldId];
|
|
252
|
+
for (const [alias, fieldId] of Object.entries(aliases)) result[alias] = fields[fieldId] ?? null;
|
|
253
253
|
return result;
|
|
254
254
|
}
|
|
255
255
|
function dealiasData(data, select) {
|
package/dist/index.d.ts
CHANGED
|
@@ -6,8 +6,7 @@ import { SoftrContext } from "./auth/context.js";
|
|
|
6
6
|
import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
|
|
7
7
|
import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
|
|
8
8
|
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
|
|
9
|
-
import { softrMcpMiddleware } from "./mcp.js";
|
|
10
9
|
import { useUserTimezone } from "./timezone.js";
|
|
11
10
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
12
11
|
import { Audience } from "./visibility/audience.js";
|
|
13
|
-
export { type ActionPermissions, Audience, DefaultErrorOverlay, type FileAttachment, type FileItem, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware,
|
|
12
|
+
export { type ActionPermissions, Audience, DefaultErrorOverlay, type FileAttachment, type FileItem, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
|
package/dist/index.js
CHANGED
|
@@ -3,9 +3,8 @@ import { defineIntegration, defineMutation, defineQuery, defineWorkflow } from "
|
|
|
3
3
|
import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
|
|
4
4
|
import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
|
|
5
5
|
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
|
|
6
|
-
import { softrMcpMiddleware } from "./mcp.js";
|
|
7
6
|
import { useUserTimezone } from "./timezone.js";
|
|
8
7
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
9
8
|
import { Audience } from "./visibility/audience.js";
|
|
10
9
|
|
|
11
|
-
export { Audience, DefaultErrorOverlay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware,
|
|
10
|
+
export { Audience, DefaultErrorOverlay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
|
package/dist/package.js
CHANGED
package/dist/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { upload } from "./core/file-upload.js";
|
|
2
|
-
import { ds } from "./datasource/datasource.js";
|
|
3
2
|
import { auth } from "./auth/server.js";
|
|
3
|
+
import { ds } from "./datasource/datasource.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
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
metadata:
|
|
12
12
|
type: core
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.46
|
|
14
|
+
library_version: 0.0.46
|
|
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
|
|
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.
|
|
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`.
|
|
@@ -47,7 +47,6 @@ 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
|
-
| Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
|
|
51
50
|
|
|
52
51
|
## Quick Decision Tree
|
|
53
52
|
|
|
@@ -79,16 +78,13 @@ Wiring a login, signup, or sign-out?
|
|
|
79
78
|
Calling a connected third-party API (Slack, Stripe, Gmail, Google Calendar, …)?
|
|
80
79
|
load softr-sdk/integrations
|
|
81
80
|
|
|
82
|
-
Opening the app to an AI assistant over MCP?
|
|
83
|
-
load softr-sdk/mcp
|
|
84
|
-
|
|
85
81
|
Writing a server function, loader, or middleware in general?
|
|
86
82
|
not a Softr concern. Use the TanStack Start skills.
|
|
87
83
|
```
|
|
88
84
|
|
|
89
85
|
## The wiring
|
|
90
86
|
|
|
91
|
-
Already present in the app. Read it to understand where the visitor comes from. Do not recreate it.
|
|
87
|
+
Already present in the app. Read it to understand where the visitor comes from. Do not recreate it.
|
|
92
88
|
|
|
93
89
|
```ts
|
|
94
90
|
// 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.46
|
|
19
|
+
library_version: 0.0.46
|
|
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.46
|
|
15
|
+
library_version: 0.0.46
|
|
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.
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.46
|
|
13
|
+
library_version: 0.0.46
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
**Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
name: integrations
|
|
3
3
|
description: >-
|
|
4
4
|
Call a third-party service connected to the app as an integration:
|
|
5
|
-
defineIntegration from @softr/sdk declares the action with its integrationId
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
body. Check res.ok
|
|
5
|
+
defineIntegration from @softr/sdk declares the action with its integrationId,
|
|
6
|
+
the one HTTP method and endpoint url it allows, and permissions;
|
|
7
|
+
integration.createFetcher from @softr/sdk/server binds it and returns a fetcher
|
|
8
|
+
function you call with optional params, query, headers, and body. Check res.ok
|
|
9
|
+
before res.json().
|
|
9
10
|
metadata:
|
|
10
11
|
type: sub-skill
|
|
11
12
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.46
|
|
13
|
+
library_version: 0.0.46
|
|
13
14
|
---
|
|
14
15
|
|
|
15
16
|
**Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -35,55 +36,87 @@ When the user attaches integrations to the chat, they appear in the `## Attached
|
|
|
35
36
|
- integration `<integrationId>`, type `GMAIL`
|
|
36
37
|
```
|
|
37
38
|
|
|
38
|
-
Pass the `integration` value as `integrationId` in `defineIntegration`. The `database` value is the table coordinate; integrations do not use it, so do not pass it anywhere. Use `integrationType` (e.g. `GMAIL`) to look up the base URL from the reference table below — then construct the full URL path
|
|
39
|
+
Pass the `integration` value as `integrationId` in `defineIntegration`. The `database` value is the table coordinate; integrations do not use it, so do not pass it anywhere. Use `integrationType` (e.g. `GMAIL`) to look up the base URL from the reference table below — then construct the full URL path in the `url` field. Pass query parameters via `query` on the fetcher call, not in the declared `url`.
|
|
39
40
|
|
|
40
41
|
Either shape configures `defineIntegration`. An entry with no `database` coordinate carries the `integrationId`, which is all the call needs.
|
|
41
42
|
|
|
42
43
|
## Declare the integration action
|
|
43
44
|
|
|
44
45
|
```ts
|
|
45
|
-
const
|
|
46
|
-
id: '
|
|
46
|
+
const listCalendarEvents = defineIntegration({
|
|
47
|
+
id: 'listCalendarEvents',
|
|
47
48
|
integrationId: '25d60213-ef4f-4fa0-b094-a46931b2d120',
|
|
49
|
+
method: 'GET',
|
|
50
|
+
url: 'https://www.googleapis.com/calendar/v3/calendars/primary/events',
|
|
48
51
|
permissions: { userGroup: 'LOGGED_IN_USERS' },
|
|
49
52
|
})
|
|
50
53
|
```
|
|
51
54
|
|
|
52
55
|
Write the object inline in `defineIntegration`. Passing it through a variable widens the types.
|
|
53
56
|
|
|
57
|
+
The action allows exactly one HTTP `method` and one endpoint `url`. The proxy denies a call whose method does not match. So **declare one action per operation** — reading a record and deleting it are two actions, each with its own `id` and its own audience.
|
|
58
|
+
|
|
59
|
+
Declare the **full endpoint path**, not just the host — one action names one endpoint. For a dynamic path segment, write a `{placeholder}` token with any name — for example `url: 'https://api.stripe.com/v1/customers/{id}'` or `url: 'https://api.stripe.com/v1/invoices/{invoiceId}/void'`. Keep query parameters out of the declared `url`; add them to the call.
|
|
60
|
+
|
|
54
61
|
## Core pattern
|
|
55
62
|
|
|
56
|
-
`integration.createFetcher(action)` binds the action and returns a fetcher function.
|
|
63
|
+
`integration.createFetcher(action)` binds the action and returns a fetcher function. The `method` and `url` come from the action — you do not repeat them on the call. Pass an optional init object with:
|
|
64
|
+
|
|
65
|
+
- `params` — fills `{placeholder}` tokens in the declared url, one key per token.
|
|
66
|
+
- `query` — appended as a query string (`?key=value`).
|
|
67
|
+
- `headers` — extra request headers.
|
|
68
|
+
- `body` — request body string.
|
|
69
|
+
- `signal` — abort signal.
|
|
57
70
|
|
|
58
71
|
```ts
|
|
59
72
|
const getCalendarEvents = createServerFn().handler(async () => {
|
|
60
|
-
const calendar = integration.createFetcher(
|
|
73
|
+
const calendar = integration.createFetcher(listCalendarEvents)
|
|
61
74
|
const now = new Date().toISOString()
|
|
62
|
-
const res = await calendar(
|
|
63
|
-
|
|
64
|
-
)
|
|
75
|
+
const res = await calendar({
|
|
76
|
+
query: { timeMin: now, singleEvents: 'true', orderBy: 'startTime', maxResults: '10' },
|
|
77
|
+
})
|
|
65
78
|
if (!res.ok) throw new Error(`Integration request failed: ${res.status} ${await res.text()}`)
|
|
66
79
|
return res.json()
|
|
67
80
|
})
|
|
68
81
|
```
|
|
69
82
|
|
|
70
|
-
|
|
83
|
+
For a dynamic path segment, pass `params` matching each `{placeholder}` token:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const getStripeCustomer = defineIntegration({
|
|
87
|
+
id: 'getStripeCustomer',
|
|
88
|
+
integrationId: 'wsint-stripe',
|
|
89
|
+
method: 'GET',
|
|
90
|
+
url: 'https://api.stripe.com/v1/customers/{id}',
|
|
91
|
+
permissions: { userGroup: 'LOGGED_IN_USERS' },
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const fetchCustomer = createServerFn({ method: 'GET' })
|
|
95
|
+
.validator((data: { customerId: string }) => data)
|
|
96
|
+
.handler(async ({ data }) => {
|
|
97
|
+
const stripe = integration.createFetcher(getStripeCustomer)
|
|
98
|
+
const res = await stripe({ params: { id: data.customerId } })
|
|
99
|
+
if (!res.ok) throw new Error(`Stripe error: ${res.status} ${await res.text()}`)
|
|
100
|
+
return res.json()
|
|
101
|
+
})
|
|
102
|
+
```
|
|
71
103
|
|
|
72
104
|
## POST request
|
|
73
105
|
|
|
74
106
|
```ts
|
|
75
|
-
const
|
|
76
|
-
id: '
|
|
107
|
+
const postSlackMessage = defineIntegration({
|
|
108
|
+
id: 'postSlackMessage',
|
|
77
109
|
integrationId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
|
110
|
+
method: 'POST',
|
|
111
|
+
url: 'https://slack.com/api/chat.postMessage',
|
|
78
112
|
permissions: { userGroup: 'LOGGED_IN_USERS' },
|
|
79
113
|
})
|
|
80
114
|
|
|
81
115
|
const sendSlackMessage = createServerFn({ method: 'POST' })
|
|
82
116
|
.validator((data: { channel: string; text: string }) => data)
|
|
83
117
|
.handler(async ({ data }) => {
|
|
84
|
-
const slack = integration.createFetcher(
|
|
85
|
-
const res = await slack(
|
|
86
|
-
method: 'POST',
|
|
118
|
+
const slack = integration.createFetcher(postSlackMessage)
|
|
119
|
+
const res = await slack({
|
|
87
120
|
headers: { 'Content-Type': 'application/json' },
|
|
88
121
|
body: JSON.stringify({ channel: data.channel, text: data.text }),
|
|
89
122
|
})
|
|
@@ -163,3 +196,5 @@ If the API the user needs is not in the table above, call it directly from a ser
|
|
|
163
196
|
**Setting Authorization headers.** Softr injects credentials automatically. A manually set `Authorization` header overrides the injected one and the request is rejected by the third-party API.
|
|
164
197
|
|
|
165
198
|
**Not checking `res.ok`.** The fetcher returns the proxied response as-is. A 4xx or 5xx from the third-party API is not thrown — call `res.ok` or `res.status` before calling `res.json()`.
|
|
199
|
+
|
|
200
|
+
**Reusing one action for a different endpoint.** An action authorizes one `method` and one endpoint `url`. A different endpoint belongs to a different action — declare one for it, with its own audience.
|
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
metadata:
|
|
12
12
|
type: sub-skill
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.46
|
|
14
|
+
library_version: 0.0.46
|
|
15
15
|
---
|
|
16
16
|
|
|
17
17
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -53,6 +53,8 @@ The declaration follows the same rules as a read, spelled out in [record-queries
|
|
|
53
53
|
|
|
54
54
|
The mutation's `fields` bound the write: `data` can only name them, and the returned record only carries them. A delete mutation declares no `fields`, and giving it some is a type error.
|
|
55
55
|
|
|
56
|
+
An update or a delete must not be open to visitors: its `permissions` must be `{ userGroup: 'LOGGED_IN_USERS' }`. A visitor who is not logged in must never change or remove a record, so `ALL_USERS` and `NON_LOGGED_IN_USERS` are rejected for these two operations. A create may stay open to visitors, for a sign-up or a contact form.
|
|
57
|
+
|
|
56
58
|
## Core Patterns
|
|
57
59
|
|
|
58
60
|
### Create a record
|
|
@@ -13,7 +13,7 @@ description: >-
|
|
|
13
13
|
metadata:
|
|
14
14
|
type: sub-skill
|
|
15
15
|
library: softr-sdk
|
|
16
|
-
library_version: 0.0.46
|
|
16
|
+
library_version: 0.0.46
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
**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.
|
|
@@ -5,14 +5,15 @@ description: >-
|
|
|
5
5
|
with ALL_USERS, LOGGED_IN_USERS or NON_LOGGED_IN_USERS, userGroupIds, and the
|
|
6
6
|
ways to apply a rule: guardRoute and assertRouteAccess in a route,
|
|
7
7
|
isAuthorized and checkAccess anywhere. Covers per-reason overrides, the
|
|
8
|
-
redirect and throw strategies, rendering an access error with
|
|
9
|
-
isUnauthenticatedError, isForbiddenError and
|
|
10
|
-
guarding a layout, and the lint rule that requires a
|
|
11
|
-
rules that reject a duplicate, computed or
|
|
8
|
+
redirect and throw strategies, rendering an access error with
|
|
9
|
+
isAccessError, isUnauthenticatedError, isForbiddenError and
|
|
10
|
+
isAlreadyAuthenticatedError, guarding a layout, and the lint rule that requires a
|
|
11
|
+
declaration alongside the rules that reject a duplicate, computed or
|
|
12
|
+
root-level one.
|
|
12
13
|
metadata:
|
|
13
14
|
type: sub-skill
|
|
14
15
|
library: softr-sdk
|
|
15
|
-
library_version: 0.0.46
|
|
16
|
+
library_version: 0.0.46
|
|
16
17
|
---
|
|
17
18
|
|
|
18
19
|
**Boundary.** `defineVisibility`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.46
|
|
12
|
+
library_version: 0.0.46
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.
|
package/dist/mcp.d.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { DatasourceAction } from "./action.js";
|
|
2
|
-
import * as _tanstack_react_start2 from "@tanstack/react-start";
|
|
3
|
-
|
|
4
|
-
//#region src/mcp.d.ts
|
|
5
|
-
|
|
6
|
-
type SoftrMcpOptions = {
|
|
7
|
-
/**
|
|
8
|
-
* The actions to expose as tools, from `defineQuery` and `defineMutation`.
|
|
9
|
-
*
|
|
10
|
-
* Listing them is deliberate: an action left out is invisible over MCP, even
|
|
11
|
-
* though the app's own pages may use it. The action's own `permissions` still
|
|
12
|
-
* decide who may run the ones that are listed.
|
|
13
|
-
*/
|
|
14
|
-
actions?: Array<DatasourceAction>;
|
|
15
|
-
};
|
|
16
|
-
/**
|
|
17
|
-
* Serves the MCP endpoint and its OAuth flow from the app's own origin.
|
|
18
|
-
*
|
|
19
|
-
* Register it in the app's `src/start.ts`, ahead of `softrAuthMiddleware`: it
|
|
20
|
-
* answers its own paths and resolves the caller from the bearer token, so it
|
|
21
|
-
* must not sit behind the session lookup that middleware does on every
|
|
22
|
-
* request. Everything else passes straight through.
|
|
23
|
-
*
|
|
24
|
-
* @example
|
|
25
|
-
* import { publishedPosts, addCafe } from './actions'
|
|
26
|
-
*
|
|
27
|
-
* export const startInstance = createStart(() => ({
|
|
28
|
-
* requestMiddleware: [
|
|
29
|
-
* softrMcpMiddleware({ actions: [publishedPosts, addCafe] }),
|
|
30
|
-
* softrAuthMiddleware(),
|
|
31
|
-
* ],
|
|
32
|
-
* }))
|
|
33
|
-
*/
|
|
34
|
-
declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start2.RequestMiddlewareAfterServer<{}, undefined, undefined>;
|
|
35
|
-
//#endregion
|
|
36
|
-
export { softrMcpMiddleware };
|
package/dist/mcp.js
DELETED
|
@@ -1,581 +0,0 @@
|
|
|
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
|
-
/** Where a visitor signs in. The blank template routes `_auth/login` here. */
|
|
18
|
-
const LOGIN_PATH = "/login";
|
|
19
|
-
/** What the platform grants. Advertised here so a client asks for the same. */
|
|
20
|
-
const SCOPES = ["mcp:read", "mcp:write"];
|
|
21
|
-
/** What a client that named no scope gets. Matches the platform's own default. */
|
|
22
|
-
const DEFAULT_SCOPE = "mcp:read";
|
|
23
|
-
const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26"];
|
|
24
|
-
/**
|
|
25
|
-
* Serves the MCP endpoint and its OAuth flow from the app's own origin.
|
|
26
|
-
*
|
|
27
|
-
* Register it in the app's `src/start.ts`, ahead of `softrAuthMiddleware`: it
|
|
28
|
-
* answers its own paths and resolves the caller from the bearer token, so it
|
|
29
|
-
* must not sit behind the session lookup that middleware does on every
|
|
30
|
-
* request. Everything else passes straight through.
|
|
31
|
-
*
|
|
32
|
-
* @example
|
|
33
|
-
* import { publishedPosts, addCafe } from './actions'
|
|
34
|
-
*
|
|
35
|
-
* export const startInstance = createStart(() => ({
|
|
36
|
-
* requestMiddleware: [
|
|
37
|
-
* softrMcpMiddleware({ actions: [publishedPosts, addCafe] }),
|
|
38
|
-
* softrAuthMiddleware(),
|
|
39
|
-
* ],
|
|
40
|
-
* }))
|
|
41
|
-
*/
|
|
42
|
-
function softrMcpMiddleware(options = {}) {
|
|
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);
|
|
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) {
|
|
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));
|
|
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
|
-
const fields = Array.from(url.searchParams.entries()).map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(value)}">`).join("");
|
|
538
|
-
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)}">
|
|
539
|
-
${fields}
|
|
540
|
-
<button type="submit" name="decision" value="allow" class="primary">Allow</button>
|
|
541
|
-
<button type="submit" name="decision" value="deny">Cancel</button>
|
|
542
|
-
</form>`);
|
|
543
|
-
}
|
|
544
|
-
function signInPage(url) {
|
|
545
|
-
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="${LOGIN_PATH}?redirect=${encodeURIComponent(url.pathname + url.search)}">Sign in</a>`);
|
|
546
|
-
}
|
|
547
|
-
function page(title, body, actions = "") {
|
|
548
|
-
return `<!doctype html>
|
|
549
|
-
<html lang="en">
|
|
550
|
-
<head>
|
|
551
|
-
<meta charset="utf-8">
|
|
552
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
553
|
-
<title>${escapeHtml(title)}</title>
|
|
554
|
-
<style>
|
|
555
|
-
:root { color-scheme: light dark; }
|
|
556
|
-
body {
|
|
557
|
-
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
558
|
-
font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
559
|
-
background: Canvas; color: CanvasText; padding: 24px;
|
|
560
|
-
}
|
|
561
|
-
main { max-width: 26rem; }
|
|
562
|
-
h1 { font-size: 1.35rem; margin: 0 0 .6rem; }
|
|
563
|
-
p { margin: 0 0 1.5rem; opacity: .8; }
|
|
564
|
-
form { display: flex; gap: .6rem; flex-wrap: wrap; }
|
|
565
|
-
button, a.primary {
|
|
566
|
-
font: inherit; padding: .55rem 1.1rem; border-radius: .4rem; cursor: pointer;
|
|
567
|
-
border: 1px solid currentColor; background: transparent; color: inherit;
|
|
568
|
-
text-decoration: none; display: inline-block;
|
|
569
|
-
}
|
|
570
|
-
.primary { background: CanvasText; color: Canvas; border-color: CanvasText; }
|
|
571
|
-
</style>
|
|
572
|
-
</head>
|
|
573
|
-
<body><main><h1>${escapeHtml(title)}</h1><p>${body}</p>${actions}</main></body>
|
|
574
|
-
</html>`;
|
|
575
|
-
}
|
|
576
|
-
function escapeHtml(value) {
|
|
577
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
//#endregion
|
|
581
|
-
export { softrMcpMiddleware };
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: mcp
|
|
3
|
-
description: >-
|
|
4
|
-
Open the app to AI assistants over MCP: softrMcpMiddleware({ actions }) from
|
|
5
|
-
@softr/sdk, registered in src/start.ts ahead of softrAuthMiddleware, turns the
|
|
6
|
-
actions you list into tools an assistant can call as the signed-in visitor.
|
|
7
|
-
The list is explicit — an action left out is invisible over MCP. The app's own
|
|
8
|
-
login must honour a `redirect` search param, or the approval step dead-ends.
|
|
9
|
-
metadata:
|
|
10
|
-
type: sub-skill
|
|
11
|
-
library: softr-sdk
|
|
12
|
-
library_version: 0.0.46-alpha.2
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
**Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.
|
|
16
|
-
|
|
17
|
-
# MCP
|
|
18
|
-
|
|
19
|
-
An app can answer an AI assistant on its own origin. The assistant signs in as an ordinary app user, and the actions you list become the tools it may call. Not wired by default — a new app serves no MCP endpoint until you add the middleware.
|
|
20
|
-
|
|
21
|
-
## The URL
|
|
22
|
-
|
|
23
|
-
The app's own public origin, plus `/_softr/mcp`:
|
|
24
|
-
|
|
25
|
-
```
|
|
26
|
-
https://myapp.mysoftr.app/_softr/mcp
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
Custom domain, default subdomain, a staging host — whatever origin the app is published on, the path is the same. That whole string is the MCP URL. The origin on its own is the app's home page, and an assistant pointed at it gets HTML back.
|
|
30
|
-
|
|
31
|
-
## Setup
|
|
32
|
-
|
|
33
|
-
```ts
|
|
34
|
-
// src/start.ts
|
|
35
|
-
import { createCsrfMiddleware, createStart } from '@tanstack/react-start'
|
|
36
|
-
import { softrAuthMiddleware, softrMcpMiddleware } from '@softr/sdk'
|
|
37
|
-
import { listCafes, addCafe } from './actions'
|
|
38
|
-
|
|
39
|
-
export const startInstance = createStart(() => ({
|
|
40
|
-
requestMiddleware: [
|
|
41
|
-
createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn' }),
|
|
42
|
-
softrMcpMiddleware({ actions: [listCafes, addCafe] }),
|
|
43
|
-
softrAuthMiddleware(),
|
|
44
|
-
],
|
|
45
|
-
}))
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Order is part of the contract. `softrMcpMiddleware` goes **before** `softrAuthMiddleware`: an assistant arrives with a token, not a browser session, so it must not hit the session lookup first.
|
|
49
|
-
|
|
50
|
-
## Choosing the actions
|
|
51
|
-
|
|
52
|
-
The list is the whole tool surface. Write it out:
|
|
53
|
-
|
|
54
|
-
- An action you leave out is invisible to the assistant, even where the app's pages use it freely.
|
|
55
|
-
- An action you list still obeys its own `permissions`. Listing it does not widen who may run it — the assistant runs as whoever signed in, with that user's groups.
|
|
56
|
-
- Two actions with the same `id` throw at startup. So does anything that is not a `defineQuery` or `defineMutation` value.
|
|
57
|
-
|
|
58
|
-
Reads first. Add a write only when the user asked for one, and name it back to them.
|
|
59
|
-
|
|
60
|
-
## The login round trip
|
|
61
|
-
|
|
62
|
-
Connecting starts with an approval page the app serves. Most people hit it signed out, and that page cannot sign anyone in. It sends them to `/login` with a `redirect` search param holding where to come back to.
|
|
63
|
-
|
|
64
|
-
**The app's login has to honour that param.** Carry it through every exit that ends a sign-in, the second-factor step included, and send the visitor there instead of the home page. Drop it anywhere along the way and the approval dies quietly: they land on the home page, the assistant never gets its answer, nothing reports an error.
|
|
65
|
-
|
|
66
|
-
Validate it before following it. A path on this app and nothing else — not a full URL, not one starting `//`. The whole flow starts with a link someone was sent, so this is the one redirect an attacker can reliably aim.
|
|
67
|
-
|
|
68
|
-
The blank template does this in `src/routes/_auth/login.tsx`, `src/routes/verify.tsx` and `src/lib/after-auth.ts`. Read those before writing your own.
|
|
69
|
-
|
|
70
|
-
## Failure modes
|
|
71
|
-
|
|
72
|
-
**Registering with no actions.** `softrMcpMiddleware()` is valid and serves an endpoint with nothing on it. The assistant connects, lists tools, finds only `whoami`. Pass the actions.
|
|
73
|
-
|
|
74
|
-
**Registering after `softrAuthMiddleware`.** The assistant's token is never read. Put the MCP middleware first.
|
|
75
|
-
|
|
76
|
-
**Handing over the app's URL as the MCP URL.** The origin is where the app's pages are. Without `/_softr/mcp` on the end the assistant has nothing to talk to, and the owner is left comparing a URL that looks right against a connection that never works.
|
|
77
|
-
|
|
78
|
-
**A login that drops `redirect`.** Sign-in works, approval doesn't. The most common break, and silent — test the connect flow signed out, not only signed in.
|
|
79
|
-
|
|
80
|
-
**Following `redirect` unchecked.** Anything that is not a path on this app is someone else's page. Reject it, fall back to `/`.
|
|
81
|
-
|
|
82
|
-
## References
|
|
83
|
-
|
|
84
|
-
- [record-queries/SKILL.md](../record-queries/SKILL.md): declaring the read actions you expose
|
|
85
|
-
- [record-mutations/SKILL.md](../record-mutations/SKILL.md): declaring the write actions you expose
|
|
86
|
-
- [auth/SKILL.md](../auth/SKILL.md): the sign-in and second-factor flows the round trip passes through
|