@exposify/next 0.0.0-stage → 0.0.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Exposify
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,329 @@
1
- # Temporary Holding Version
1
+ # @exposify/next
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Next.js integration for [Exposify](../../README.md): your route handlers,
4
+ pages and Server Actions become MCP tools that AI agents can call, at `/mcp` on
5
+ your own app.
6
+
7
+ ```sh
8
+ npm install @exposify/next
9
+ npx exposify-next setup # pick the endpoints, get the route file to create
10
+ ```
11
+
12
+ Or by hand, in two files:
13
+
14
+ ```ts
15
+ // next.config.ts
16
+ import { withExposify } from "@exposify/next/config";
17
+
18
+ export default withExposify(nextConfig);
19
+ ```
20
+
21
+ ```ts
22
+ // app/mcp/[[...transport]]/route.ts
23
+ import { aiReady } from "@exposify/next";
24
+ import { exposify } from "../../../exposify.generated";
25
+
26
+ const handler = aiReady(exposify, { expose: ["POST /api/orders"] });
27
+
28
+ export { handler as GET, handler as POST, handler as DELETE };
29
+ ```
30
+
31
+ `withExposify()` writes `exposify.generated.ts` whenever `next dev` or `next
32
+ build` starts, and keeps it current in dev as route files change. It holds
33
+ the route table, your Server Actions, your docs (`README.md`, `docs/**/*.md`) for `search_docs`,
34
+ and `exposify.descriptions.json`'s texts, so nothing is read from disk at
35
+ runtime. Commit it or ignore it; `npx exposify-next generate` writes it too.
36
+
37
+ ## Which routes become tools
38
+
39
+ Every method an `app/**/route.ts` exports: reads (`GET`) are exposed by
40
+ default, writes need an opt-in, as with every Exposify adapter.
41
+
42
+ ```ts
43
+ import { describe, exposed, hidden } from "@exposify/next";
44
+ import { z } from "zod";
45
+
46
+ export async function GET() {} // exposed (a read)
47
+ export const POST = exposed(async (req) => {}); // a write, opted in
48
+ export const DELETE = hidden(async () => {}); // never a tool
49
+
50
+ export const PATCH = describe(async (req) => {}, {
51
+ name: "update_order",
52
+ description: "Change an order's quantity",
53
+ input: { id: z.string(), body: z.object({ qty: z.number().int().min(1) }) },
54
+ });
55
+ ```
56
+
57
+ Or in config: `expose: ["POST /api/orders"]`, `exclude: ["/api/admin/**"]`.
58
+ Paths are what Next serves: route groups like `(shop)` drop out, `[id]`
59
+ becomes `:id`, `[...slug]` becomes `:slug*` and `[[...slug]]` becomes
60
+ `:slug*?` (the tool takes `"a/b/c"`).
61
+
62
+ **Pages Router** (`pages/api/*`): one handler serves every method, so nothing
63
+ proves which methods exist. A Pages route becomes a tool only for the
64
+ methods you list, reads included: `expose: ["GET /api/stats"]`. `setup`
65
+ reads your handler (`if (req.method === …)`, `switch (req.method)`,
66
+ next-connect's `.get()`/`.post()`) and suggests them.
67
+
68
+ ## How tool calls run
69
+
70
+ A tool call is an HTTP request to your own app's origin, with the agent's
71
+ `Authorization` and `Cookie` headers. So `proxy.ts` (middleware), and each
72
+ route's own checks, run exactly as for a browser: a signed-out agent gets
73
+ your 401, and a redirect to your login page comes back as an error naming
74
+ it. The MCP client's address and host are forwarded as
75
+ `x-forwarded-for`/`x-forwarded-host`.
76
+
77
+ - `baseUrl` (or `EXPOSIFY_BASE_URL`): where tool calls go, when the request's
78
+ own origin isn't reachable from the server.
79
+ - **Vercel Deployment Protection** blocks those requests on protected
80
+ previews: set `VERCEL_AUTOMATION_BYPASS_SECRET` (Vercel's "Protection
81
+ Bypass for Automation") and it's sent as `x-vercel-protection-bypass`.
82
+ - `basePath` and `trailingSlash` from `next.config` are applied.
83
+ - `rateLimit` / `rateWindowMs`: MCP requests per client address per window
84
+ (default 60 per 60 s), in memory and per instance.
85
+ - `oauth`, `auth`, `docsAuth` and `openapi` work as in `@exposify/express`.
86
+ With `oauth`, the protected-resource metadata is served at
87
+ `/mcp/.well-known/oauth-protected-resource`.
88
+
89
+ The MCP route runs on the Node.js runtime. Routes with `export const runtime
90
+ = "edge"` are tools too, but their `exposed()`/`describe()` wrappers aren't
91
+ read (their modules aren't loaded into the Node route): use `expose`/`exclude`
92
+ for those.
93
+
94
+ ## Each user sees the tools they can use
95
+
96
+ Tools behind your own checks are listed only for people who pass them. The
97
+ generator notes which of your functions a page or Server Action calls that
98
+ stop a request with `notFound()`, `redirect()`, `forbidden()` or
99
+ `unauthorized()` (say `requireAdmin()` and `requireUser()`), directly or
100
+ through another one. When an agent lists tools, the MCP route opens one page
101
+ per check as that agent's user (a page without params that runs it), and
102
+ leaves out every tool behind a check they don't pass. So a customer's
103
+ assistant doesn't see `view_admin` or `save_product`; an admin's does.
104
+
105
+ - One request per check per user, cached for a minute.
106
+ - A check with no page to try it on, or a probe that errors, leaves its
107
+ tools listed. Your checks still run on every call either way: this only
108
+ changes what's listed.
109
+ - Checks written inline in a page (`if (!user) redirect("/login")`) aren't
110
+ shared functions, so those pages stay listed.
111
+
112
+ ## AI assistants sign in with your own login
113
+
114
+ With `EXPOSIFY_KEY` set (the hosted broker), an AI assistant that connects
115
+ signs in with your app's own login, and you write no code for it:
116
+
117
+ 1. The generator finds your login: a Server Action (or a `POST` route
118
+ handler) that takes a password and something saying who, and sets a
119
+ session cookie or returns a token. Sign-up, sign-out and password-reset
120
+ actions are never picked.
121
+ 2. When Claude connects, the person clicks Allow on Exposify's page and lands
122
+ on a sign-in form this MCP route serves at `/mcp/login`, on your domain.
123
+ The password goes to your app only.
124
+ 3. The form runs your login exactly as your own form does (a real Server
125
+ Action on the page that uses it), with no browser cookies, so it's a
126
+ session of its own. Its cookies go to the broker for this connection, and
127
+ every tool call after that runs as that person.
128
+ 4. Cookies your app sets on later calls are kept. A 401, or a redirect to
129
+ your login page, ends the connection, and the assistant asks to connect
130
+ again.
131
+
132
+ The MCP route tells Exposify about the login (its field names) and its
133
+ address before answering its first request, so there's nothing to add in
134
+ the dashboard; it shows there with an off switch. `inspect` says which login
135
+ was found. Name another one with `aiReady(exposify, { login: "signIn" })`
136
+ (an action's export name) or `{ login: { path: "/api/login", fields:
137
+ ["email", "password"] } }`, or turn it off with `login: false`.
138
+
139
+ **No login found, and no sign-in method in the dashboard?** Then agents use
140
+ your app signed out: `/mcp` doesn't ask them to sign in, and your own checks
141
+ decide what they can do. The startup log says which it is
142
+ (`[agentready] How agents sign in: ...`), and so does the dashboard's "How
143
+ agents sign in" page. Adding a sign-in method there takes effect within a
144
+ minute.
145
+
146
+ Not supported yet: logins in more than one step (SMS codes, email links),
147
+ and logins that need a CSRF token from your own form page.
148
+
149
+ ## Pages
150
+
151
+ Every App Router page is a read tool too, so an app whose server components
152
+ read their data directly (a database client, an ORM, `fetch`, `"use cache"`
153
+ functions), with no API route in between, is readable by agents with no code
154
+ changes. `/shop/[category]` becomes `view_shop_category { category }`, `/`
155
+ becomes `view_home`.
156
+
157
+ A call requests the page the way a browser does, with the agent's
158
+ `Authorization` and `Cookie`, so your own checks run: `proxy.ts`, a login
159
+ check in a layout or the page, `notFound()`, `redirect()`. It returns:
160
+
161
+ ```json
162
+ { "url": "/product/celeste", "title": "Celeste | Shop", "text": "# Celeste\n\n$348.00\n…", "data": [{ "component": "AddToCart", "props": { "product": { … } } }] }
163
+ ```
164
+
165
+ - `text` is the page's main content (`<main>`, or the body without its
166
+ header, nav, footer and aside) as compact text: headings, lists, tables,
167
+ links (a card wrapped in a link ends with `→ /its/url`), images' alt text
168
+ and form controls. Scripts, styles and hidden elements are left out.
169
+ - `data` is what the page was rendered with: the props your server
170
+ components pass to client components, read from the payload Next already
171
+ puts in the page. A page that renders its list in server components only
172
+ has no such props, and its `text` carries the list.
173
+ - A `redirect()` or `notFound()` comes back as the error a route's would
174
+ (`redirected to /login`, a 404), also when the page streams and Next has
175
+ already answered 200 (Suspense, `cacheComponents`).
176
+ - Each `[param]` is an argument; a catch-all takes `"a/b/c"`. Search params
177
+ the page reads (`searchParams.q`, `(await searchParams).page`, a
178
+ destructure, `useSearchParams().get("tab")` in a client component it
179
+ renders) are optional arguments.
180
+ - The description is the page's static `metadata` (`description`, else
181
+ `title`), the descriptions file (`GET /path`, which `describe` writes), or
182
+ a derived one.
183
+
184
+ What's left out, and how to change it:
185
+
186
+ - A page that's **just a form** posting to a Server Action (a sign-in page,
187
+ say) isn't a tool: the action is. List `"GET /login"` in `expose` (setup's
188
+ checklist does) to read it anyway.
189
+ - `exclude` globs apply to pages, as to routes: `exclude: ["/admin/**"]`.
190
+ - `pages: false` turns page tools off. By path, `false` hides one and an
191
+ object renames or describes one:
192
+ `pages: { "/": { name: "storefront", description: "…" }, "/debug": false }`.
193
+
194
+ Data a page fetches in the browser after it loads (`useEffect`, SWR) isn't in
195
+ its HTML, so its tool doesn't see it. A page tool is a full render: cheap for
196
+ cached pages, a normal request otherwise.
197
+
198
+ ## Server Actions
199
+
200
+ The exported functions of every `"use server"` file are listed in the
201
+ generated manifest, with the input each one takes, worked out from its code:
202
+ the fields it reads off its FormData (`formData.get("email")`, through
203
+ same-file helpers like `field("email")` or `parseProduct(formData)`, and
204
+ names built over a constant list, like `` `stock_${size}` `` for each of
205
+ `SIZES`), and their types (`Number(…)` is a number, `=== "on"` a checkbox,
206
+ `JSON.parse(…)` JSON, `STATUSES.includes(…)` one of `STATUSES`). Constant
207
+ lists are followed into the files they're imported from, through your
208
+ tsconfig `paths`.
209
+
210
+ Then the forms that submit to each action (`<form action={…}>`,
211
+ `onSubmit={…}` around it, `<button formAction={…}>`, through
212
+ `useActionState` or `.bind(null, id)`, or an inline wrapper that calls it)
213
+ add what only the form says: which fields are `required`, `min`/`max`,
214
+ `minLength`/`maxLength`, `pattern`, `<select>` and radio options, and which
215
+ of the action's parameters are `.bind` arguments or `useActionState`'s
216
+ previous state. Wrapper components (`<Field name="email" />` rendering
217
+ `<input required {...props} />`) are followed to the input they render. A
218
+ field required by every form that has it is required in the tool; hidden
219
+ inputs count as required unless they're rendered conditionally.
220
+
221
+ Actions are writes, so none is a tool until you opt it in:
222
+
223
+ ```ts
224
+ // app/mcp/[[...transport]]/route.ts
225
+ const handler = aiReady(exposify, {
226
+ actions: {
227
+ placeOrder: { description: "Place an order" }, // input worked out from its code
228
+ // Or written out, which wins: form field names, and a parameter before the FormData (a `.bind` argument) by its name.
229
+ updateOrderStatus: { input: { orderId: z.string(), status: z.enum(["paid", "shipped"]) } },
230
+ clearCart: true,
231
+ },
232
+ });
233
+ ```
234
+
235
+ Actions fill in defaults for missing fields instead of rejecting them, so a
236
+ wrong schema would let an agent save wrong data. An action whose input
237
+ can't all be worked out stays hidden, with a warning naming what (a field
238
+ name computed at runtime, the form handed to a function from a package, a
239
+ parameter with no type), until you write its `input`; so does one that reads
240
+ the whole form (`Object.fromEntries(formData)`) with no form found to say
241
+ which fields it gets, and one that takes a file, which agents can't send
242
+ yet. Fields no form requires are optional; the tool's description tells the
243
+ agent a field left out is as if left blank.
244
+
245
+ Or in the action's own file: `export const clearCart = describe(async () => { … }, { description: "…" })`
246
+ (`exposed()` and `hidden()` work too). Tools are named after the export,
247
+ snake_cased (`place_order`), with the file's name added where two files
248
+ export the same name.
249
+
250
+ A call is sent to your app the way a browser's form sends it: a Server Action
251
+ request (`Next-Action`) to a page that uses the action, which the code
252
+ generator finds by following imports from your pages and layouts. Next runs
253
+ it as a real Server Action, so everything works as for a browser:
254
+ `updateTag()`, `refresh()`, `revalidatePath()`, `redirect()`, and
255
+ `cookies()`/`headers()`, which are the agent's. Nothing in your code changes,
256
+ and nothing needs configuring. An action no page uses has no such page: the
257
+ MCP route (it must be the `[[...transport]]` catch-all) calls it in-process
258
+ at `/mcp/actions/<tool>` instead, where Next treats it as part of a route
259
+ handler and refuses `updateTag()` and `refresh()`; the startup log names
260
+ those actions. Arguments become the FormData a browser would post: `true` is a
261
+ checked checkbox (`"on"`), `false` is left out, arrays are repeated fields,
262
+ objects are JSON, and a field the action `JSON.parse`s is sent whole as JSON. A useActionState action's previous state is `undefined`.
263
+ The result is the action's return value as JSON; a `redirect()` comes back
264
+ as `{ ok: true, redirectedTo }`, since for an action it's usually success
265
+ (saved, then off to the list page). `notFound()` and thrown errors are tool
266
+ errors; the thrown message stays in your server log.
267
+
268
+ - **Check auth inside each action.** A call goes to one page that uses the
269
+ action, and Next's own guidance already says actions must check auth
270
+ themselves rather than rely on `proxy.ts` rules for the page.
271
+ - **Sign-in actions don't work for agents.** A cookie an action sets lands on
272
+ the internal call, not the agent's client. Actions that call
273
+ `cookies().set`/`.delete`, or are named like `login`/`logout`/`register`,
274
+ get a warning if you opt them in.
275
+ - **Next checks each call as it checks a browser's**, Origin against the
276
+ forwarded host. The in-process fallback skips that, so `/mcp/actions/*`
277
+ requires a secret header. In development it's random per process; in
278
+ production, an action no page uses needs `EXPOSIFY_ACTION_SECRET` (or
279
+ `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY`, which it's derived from) set to the
280
+ same value on every instance, since the instance serving a tool call may
281
+ not be the one that sent it. Without either, those actions are off in
282
+ production and the log says so. Actions a page uses need neither.
283
+ - `"use server"` functions written inline in a component close over values
284
+ from the render, so they can't be called on their own; they're never
285
+ exposed.
286
+
287
+ ## CLI
288
+
289
+ `npx exposify-next setup | inspect | describe | generate`. The first three
290
+ are the same as `exposify-express`'s; the scan reads your route files
291
+ instead of booting the app.
292
+
293
+ Server Actions get their own rows:
294
+
295
+ - `inspect` lists each action with whether it's a tool and why, its status
296
+ (input known, input partly unknown, or flagged as sign-in/out or taking a
297
+ file), the forms that submit to it, and what the scan couldn't tell. Where
298
+ an input you wrote no longer matches the code (a field the action now
299
+ reads, an option the form dropped), it says so, and `inspect --check` fails
300
+ on it in CI. The `inspect --snapshot` file includes action tools, by their
301
+ `POST /mcp/actions/<tool>` URL.
302
+ - `setup`'s checklist lists actions after the routes: space picks each one,
303
+ and `a`/`!` (all routes, `expose: "*"`) leave them alone. Without a
304
+ terminal, use `--action placeOrder`. It prints their `actions` entries: `true`
305
+ when the scan knows the input, otherwise a zod stub with a
306
+ `// TODO(exposify):` line for each gap. Entries already in your config are
307
+ kept as you wrote them.
308
+ - After a `next build`, actions that Next knows but the scan didn't find
309
+ (re-exported from elsewhere, say) are listed.
310
+ - `describe` writes descriptions for exposed actions too, into
311
+ `exposify.descriptions.json` under each action's tool URL (`POST
312
+ /mcp/actions/place_order`). A `description` in config or `describe()` wins.
313
+ - For an action you've opted in whose input the scan can't complete,
314
+ `describe` can ask the description service to propose one from the action's
315
+ source and its forms' source. It first lists every file whose code would be
316
+ sent (form files go whole) and sends only if you answer `y`; with
317
+ `--no-source` it sends names and fields only. You see each proposed field, and anything it
318
+ still couldn't tell, then accept or skip it. An accepted input goes in
319
+ `exposify.descriptions.json`, and the MCP route uses it once
320
+ `exposify.generated.ts` is regenerated. If the action changes, the input is
321
+ stale and unused until reviewed again: the startup log and `describe
322
+ --check` say so. A written `input` always wins, and actions that take a
323
+ file stay hidden either way.
324
+ - Inline `"use server"` functions get a suggested change from `setup`: a new
325
+ `actions.ts` next to the page with the function exported. The values it
326
+ closed over become its first parameters, and the page passes them with
327
+ `.bind(null, …)`. A `TODO(exposify)` reminds you to check the caller may act
328
+ on them, since the closure was often the authorisation. It's printed,
329
+ never applied.
@@ -0,0 +1,53 @@
1
+ // src/routes.ts
2
+ var HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
3
+ var DEFAULT_PAGE_EXTENSIONS = ["tsx", "ts", "jsx", "js"];
4
+ function appRoute(dirSegments) {
5
+ const parts = [];
6
+ const params = [];
7
+ for (const seg of dirSegments) {
8
+ if (seg.startsWith("_") || seg.startsWith("@") || seg.startsWith("(.")) return null;
9
+ if (seg.startsWith("(") && seg.endsWith(")")) continue;
10
+ const part = dynamicSegment(seg, params);
11
+ if (part === null) return null;
12
+ parts.push(part);
13
+ }
14
+ return { path: `/${parts.join("/")}`, params };
15
+ }
16
+ function pagesApiRoute(segments) {
17
+ if (segments[0] !== "api") return null;
18
+ const segs = segments.at(-1) === "index" ? segments.slice(0, -1) : segments;
19
+ const parts = [];
20
+ const params = [];
21
+ for (const seg of segs) {
22
+ if (seg.startsWith("_")) return null;
23
+ const part = dynamicSegment(seg, params);
24
+ if (part === null) return null;
25
+ parts.push(part);
26
+ }
27
+ return { path: `/${parts.join("/")}`, params };
28
+ }
29
+ function dynamicSegment(seg, params) {
30
+ const optionalCatchAll = /^\[\[\.\.\.([A-Za-z0-9_]+)\]\]$/.exec(seg);
31
+ if (optionalCatchAll) {
32
+ params.push(optionalCatchAll[1]);
33
+ return `:${optionalCatchAll[1]}*?`;
34
+ }
35
+ const catchAll = /^\[\.\.\.([A-Za-z0-9_]+)\]$/.exec(seg);
36
+ if (catchAll) {
37
+ params.push(catchAll[1]);
38
+ return `:${catchAll[1]}*`;
39
+ }
40
+ const dynamic = /^\[([A-Za-z0-9_]+)\]$/.exec(seg);
41
+ if (dynamic) {
42
+ params.push(dynamic[1]);
43
+ return `:${dynamic[1]}`;
44
+ }
45
+ return seg.includes("[") ? null : seg;
46
+ }
47
+
48
+ export {
49
+ HTTP_METHODS,
50
+ DEFAULT_PAGE_EXTENSIONS,
51
+ appRoute,
52
+ pagesApiRoute
53
+ };