@aotter/mantle 0.2.1 → 0.2.2-alpha.1
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/auth/routes.d.ts +3 -1
- package/dist/auth/routes.js +15 -2
- package/dist/build/compiler.d.ts +1 -0
- package/dist/build/compiler.js +1 -1
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/bun/assets.d.ts +3 -1
- package/dist/bun/assets.js +4 -1
- package/dist/domain/describe/chunk-GMUORMOX.js +1 -0
- package/dist/domain/describe/index.js +1 -1
- package/dist/domain/describe/public.js +1 -1
- package/dist/domain/generated-tools.js +24 -9
- package/dist/infrastructure/bundle.js +30 -1
- package/dist/infrastructure/inputs.js +3 -2
- package/dist/infrastructure/wrangler.js +4 -3
- package/dist/kernel/authorization.d.ts +1 -0
- package/dist/kernel/authorization.js +12 -5
- package/dist/kernel/mcp-call.d.ts +3 -3
- package/dist/kernel/mcp-call.js +4 -4
- package/dist/kernel/mcp-handler.js +4 -3
- package/dist/kernel/mcp.d.ts +12 -10
- package/dist/kernel/mcp.js +1 -1
- package/dist/kernel/media-fetch.js +109 -0
- package/dist/kernel/media.d.ts +15 -2
- package/dist/kernel/media.js +138 -16
- package/dist/kernel/r2-media.d.ts +3 -2
- package/dist/kernel/r2-media.js +12 -8
- package/dist/kernel/result-meta.d.ts +10 -0
- package/dist/kernel/result-meta.js +1 -0
- package/dist/kernel/site-types.d.ts +54 -6
- package/dist/kernel/site.d.ts +1 -1
- package/dist/usecase/generate.js +2 -2
- package/dist/usecase/mcp-media.js +17 -5
- package/dist/usecase/mcp.js +6 -5
- package/dist/usecase/site.js +4 -2
- package/docs/examples/README.md +5 -1
- package/docs/examples/app-shell/README.md +5 -1
- package/docs/examples/app-shell/package.json +9 -9
- package/docs/examples/app-shell/src/jwks.ts +22 -0
- package/docs/examples/app-shell/src/service.ts +12 -1
- package/docs/examples/app-shell/wrangler.jsonc +1 -1
- package/docs/examples/club-events/README.md +10 -3
- package/docs/examples/club-events/manifests/club-events.yaml +42 -11
- package/docs/examples/club-events/smoke.mjs +31 -1
- package/docs/examples/club-events/src/handlers.ts +9 -8
- package/docs/examples/frontend-mcp-app/README.md +23 -0
- package/docs/examples/frontend-mcp-app/app.tsx +50 -0
- package/docs/examples/frontend-mcp-app/manifest.yaml +30 -0
- package/docs/examples/purchasing/README.md +9 -0
- package/docs/examples/purchasing/manifests/purchasing.yaml +18 -16
- package/docs/examples/purchasing/smoke.mjs +5 -0
- package/docs/examples/purchasing/wrangler.jsonc +1 -1
- package/docs/examples/recipes/wrangler.jsonc +1 -1
- package/docs/handbook/cloudflare/authentication.md +51 -0
- package/docs/handbook/cloudflare/deploy-and-operate.md +5 -0
- package/docs/handbook/cloudflare/media-r2.md +5 -2
- package/docs/handbook/cloudflare/service-entry.md +28 -7
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/mcp-apps.md +94 -18
- package/docs/handbook/guides/native-admin.md +29 -0
- package/docs/handbook/guides/performance.md +5 -1
- package/docs/handbook/guides/revisions.md +1 -1
- package/docs/handbook/guides/sql-patterns.md +33 -0
- package/docs/handbook/reference/authorization.md +1 -1
- package/docs/handbook/reference/describe.md +5 -0
- package/docs/handbook/reference/schema.md +19 -0
- package/docs/handbook/reference/site-config.md +2 -0
- package/docs/handbook/reference/surface.md +66 -10
- package/docs/handbook/releases/index.md +24 -1
- package/package.json +10 -10
- package/dist/domain/describe/chunk-CW7X5Q5T.js +0 -1
|
@@ -46,6 +46,15 @@ Mantle reads the official admin plugin’s `adminRoles` when auth loads (default
|
|
|
46
46
|
- `withAuthTasks(ctx.waitUntil.bind(ctx), () => auth.handler(request))` retains background sends/cleanup for the current request, with concurrent requests isolated. `authHardening` installs `retainAuthTask` as Better Auth's background handler. Failure logs are generic and contain no OTPs/credentials.
|
|
47
47
|
- `authSetupIncomplete()` returns 503/no-store while secrets/providers are unconfigured; do not authenticate by fallback.
|
|
48
48
|
|
|
49
|
+
`authRoutes` also gives public OTP validation failures one JSON response (400,
|
|
50
|
+
`INVALID_OTP`, private/no-store), including error headers. Better Auth still enforces
|
|
51
|
+
expiry and attempt limits. Success cookies, HTTP 429 and other protocol errors are
|
|
52
|
+
preserved. This matches error codes rather than URL spelling and does not equalize
|
|
53
|
+
response time or replace rate limiting. An application mounting `auth.handler`
|
|
54
|
+
directly can call `normalizeEmailOtpResponse(await auth.handler(request))` from
|
|
55
|
+
`@aotter/mantle/auth/routes`; apply host CORS and security headers outside this
|
|
56
|
+
normalizer. It does not alter the application's native Better Auth hooks.
|
|
57
|
+
|
|
49
58
|
Better Auth config is app-owned, so restrict who may sign up (an allowlist, an invitation) with a
|
|
50
59
|
`databaseHooks.user.create.before` hook that throws `APIError("FORBIDDEN")` from `better-auth/api` (the app shell calls
|
|
51
60
|
`src/signup.ts` there; returning `false` instead breaks email-OTP sign-in in Better Auth 1.7.7, which does not expect a refused create).
|
|
@@ -62,6 +71,48 @@ or `oauthProvider` for ordinary OAuth. These are optional app dependencies behin
|
|
|
62
71
|
Use the live grant/session hooks and route wiring in [OAuth](../guides/oauth.md); merge verification hooks with the app shell's
|
|
63
72
|
user hooks. `genericOAuth` is a login plugin, distinct from the OAuth provider.
|
|
64
73
|
|
|
74
|
+
### Passkeys
|
|
75
|
+
|
|
76
|
+
`authRoutes` may list `{ kind: "passkey" }` in `methods` when the app adds Better Auth's `@better-auth/passkey` plugin
|
|
77
|
+
in its own `auth.ts`, pinned to the same version as `better-auth`. Admin lists the method to its `SignIn` slot
|
|
78
|
+
([Customising Admin](../guides/native-admin.md#sign-in)) and renders no passkey sign-in of its own; the sign-in
|
|
79
|
+
experience is the application's.
|
|
80
|
+
|
|
81
|
+
- **`rpID` is the auth host.** Every origin under the `rpID` may ask for assertions with those passkeys, so use a parent
|
|
82
|
+
domain only when the deployment owns the whole registrable domain.
|
|
83
|
+
- **User verification is not enforced.** The plugin verifies sign-in with `requireUserVerification: false`, so a tap
|
|
84
|
+
without biometrics or a PIN signs in. Its sign-in endpoint does not prove the person is present; do not use it to
|
|
85
|
+
approve an action.
|
|
86
|
+
- `mantle generate --auth src/auth.ts` adds the plugin's `passkey` table to `schema.sql`.
|
|
87
|
+
|
|
88
|
+
### Verifying access tokens on Workers
|
|
89
|
+
|
|
90
|
+
`resolveCaller` verifies a Bearer or DPoP access token with Better Auth's resource client, which fetches the
|
|
91
|
+
issuer's keys from `<baseURL>/jwks` by URL and caches them per isolate for five minutes. When the issuer is the
|
|
92
|
+
same Worker, each request that lands on an isolate without those keys first makes a public-network request back
|
|
93
|
+
into the Worker, which also reads D1 and passes Better Auth's HTTP rate limiter. Better Auth 1.7 has no supported
|
|
94
|
+
in-process key source for this path yet ([better-auth/better-auth#11604](https://github.com/better-auth/better-auth/issues/11604)).
|
|
95
|
+
|
|
96
|
+
Until it does, the app shell's `src/jwks.ts` answers exactly that URL in process. `service.ts` installs it once per
|
|
97
|
+
isolate around `globalThis.fetch`, with the URL its `auth.ts` produces (`${PUBLIC_ORIGIN}/api/auth/jwks`):
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
globalThis.fetch = withInProcessJwks(globalThis.fetch, `${env.PUBLIC_ORIGIN}/api/auth/jwks`,
|
|
101
|
+
async () => (await loader.get()).api.getJwks());
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- Only a GET of that exact URL is answered, from `auth.api.getJwks()`: public keys only, under the jwt plugin's own
|
|
105
|
+
rotation rules. Every other request uses the real `fetch`.
|
|
106
|
+
- Better Auth still verifies the token, its DPoP binding and replay, and Mantle still checks the live grant. Better
|
|
107
|
+
Auth still caches the keys by that URL, so an isolate reads them once per five minutes.
|
|
108
|
+
- The call does not go through Better Auth's HTTP router, so it shares no rate-limit bucket and needs no
|
|
109
|
+
`ExecutionContext`.
|
|
110
|
+
- Keep the URL in step with `baseURL`, `basePath` and the jwt plugin's `jwksPath`. A mismatch is not a failure: the
|
|
111
|
+
request just goes over the network as before. With `jwks.remoteUrl`, keys live elsewhere: do not install it.
|
|
112
|
+
|
|
113
|
+
This patches `fetch` inside the application's own Worker; Mantle Core does not. Remove `src/jwks.ts` once Better
|
|
114
|
+
Auth's resource client accepts an in-process key source.
|
|
115
|
+
|
|
65
116
|
## Identity helpers
|
|
66
117
|
|
|
67
118
|
`@aotter/mantle/auth/identity` exports `d1Identity(db, auth, caller, request.headers)` and
|
|
@@ -60,6 +60,11 @@ can move fetch execution closer to observed back-end services:
|
|
|
60
60
|
"placement": { "mode": "smart" }
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
A placed Worker runs every request that reaches it in that location, static files
|
|
64
|
+
included. Keep static prefixes out of `assets.run_worker_first` (the generated
|
|
65
|
+
default already excludes `/admin/assets/*` and `/assets/*`) so the edge answers them next to the
|
|
66
|
+
visitor; see [Workers assets](service-entry.md#workers-assets-and-secrets).
|
|
67
|
+
|
|
63
68
|
For globally distributed, read-heavy D1 traffic, enable
|
|
64
69
|
[read replication](https://developers.cloudflare.com/d1/best-practices/read-replication/)
|
|
65
70
|
on the database and pass one request-scoped `env.DB.withSession(...)` handle to
|
|
@@ -44,7 +44,9 @@ An existing S3 signer (for example an application already using aws4fetch) fits
|
|
|
44
44
|
this port. This package adds no AWS SDK. The signer must return a SigV4
|
|
45
45
|
query-signed PUT URL and preserve `X-Amz-Expires`; never give its credentials to
|
|
46
46
|
the client. Configure bucket CORS to allow PUT from the application's origins
|
|
47
|
-
and the returned Content-Type header
|
|
47
|
+
and the returned Content-Type header (and from each MCP host's App sandbox origin
|
|
48
|
+
when you register the [upload card](../guides/mcp-apps.md#the-upload-card); its
|
|
49
|
+
CSP names the storage's `uploadOrigin`, the origin of `endpoint`). Uploads go to `uploads/<purpose>/<group>/<role>.<ext>` and each commit copies them
|
|
48
50
|
to `committed/<group>/<attempt>/<role>.<ext>`. Serve the `committed/` prefix on the public domain. Keep `uploads/`
|
|
49
51
|
private/unserved on the public domain and add a bucket lifecycle rule for abandoned upload keys. Mantle 0.1.x wrote
|
|
50
52
|
`<purpose>/<group>/<role>.<ext>` keys (`<group>/<role>.<ext>` when the purpose was not a plain slug); an asset reads its keys from the stored `storageKey` and `publicUrl` of each
|
|
@@ -52,7 +54,8 @@ variant, so 0.1.x assets keep working while their prefixes stay served.
|
|
|
52
54
|
|
|
53
55
|
Commit checks every object's MIME and size, then rechecks the exact GET
|
|
54
56
|
response used for the copy. Public keys cannot be reached by an upload URL. Each commit attempt has a separate
|
|
55
|
-
public-key UUID, while the asset id remains the upload-group
|
|
57
|
+
public-key UUID, while the asset id remains the upload-group id (`<tag>_<uuid>`; the tag is
|
|
58
|
+
this database's `site_config.media_id_tag`, so another server's id is refused as `MEDIA_NOT_OWNED`). A concurrent
|
|
56
59
|
loser can neither overwrite nor remove the winner's objects. After SQL selects
|
|
57
60
|
the durable asset, the losing attempt removes only its own unselected copies.
|
|
58
61
|
Upload sources remain until the database commit succeeds, so an uncertain DB
|
|
@@ -115,9 +115,10 @@ its collection parameter selects only a generated table and visibility policy.
|
|
|
115
115
|
## Workers assets and secrets
|
|
116
116
|
|
|
117
117
|
`mantle generate --admin --wrangler wrangler.jsonc` sets the selected assets
|
|
118
|
-
directory. A new assets block defaults to `binding: "ASSETS"`,
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
directory. A new assets block defaults to `binding: "ASSETS"`,
|
|
119
|
+
`run_worker_first: ["/*", "!/admin/assets/*", "!/assets/*"]` and `html_handling: "none"`.
|
|
120
|
+
Existing blocks keep their binding and routing; when `run_worker_first` is true or a
|
|
121
|
+
list, a missing `html_handling` defaults to `none`.
|
|
121
122
|
A different binding needs a custom Admin HTML loader. Deploy the built assets and
|
|
122
123
|
Worker together.
|
|
123
124
|
|
|
@@ -133,15 +134,32 @@ JSONC comments and unrelated settings survive regeneration:
|
|
|
133
134
|
"assets": {
|
|
134
135
|
"directory": "dist/client",
|
|
135
136
|
"binding": "ASSETS",
|
|
136
|
-
"run_worker_first":
|
|
137
|
+
"run_worker_first": ["/*", "!/admin/assets/*", "!/assets/*"],
|
|
137
138
|
"html_handling": "none"
|
|
138
139
|
},
|
|
139
140
|
"d1_databases": [{ "binding": "DB", "database_name": "your-database", "database_id": "your-id" }]
|
|
140
141
|
}
|
|
141
142
|
```
|
|
142
143
|
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
The Worker serves HTML shells and APIs; static files are answered by the edge without
|
|
145
|
+
running it. `mantle bundle` writes `<assets>/_headers` with `nosniff` and a one-year
|
|
146
|
+
`immutable` cache for Admin's content-hashed `/admin/assets/*` (an existing `_headers`
|
|
147
|
+
is not edited: the build prints the rule to add). `mantle bundle` also copies the
|
|
148
|
+
working directory's `public/` into the assets directory (removing what it copied last
|
|
149
|
+
time; `public/admin` is refused). `/assets/*` is excluded by default, so
|
|
150
|
+
`public/assets/*` is answered by the edge; other public files reach the Worker first,
|
|
151
|
+
which can serve them with `env.ASSETS.fetch(request)` as the app shell does. Cloudflare
|
|
152
|
+
sends `Cache-Control: public, max-age=0, must-revalidate` and an ETag by default; set
|
|
153
|
+
`immutable` in `public/_headers` only for content-hashed files. `_headers` does not
|
|
154
|
+
apply to responses the Worker builds. A path the Worker must answer stays out of the
|
|
155
|
+
exclusion.
|
|
156
|
+
|
|
157
|
+
After a deploy the previous version's assets are gone, so an open Admin tab that loads
|
|
158
|
+
a chunk from the old build gets a 404; Admin then offers a reload and warns that unsaved changes may be lost.
|
|
159
|
+
With [gradual deployments](https://developers.cloudflare.com/workers/versions-and-deployments/gradual-deployments/version-affinity/),
|
|
160
|
+
set a version key (`Cloudflare-Workers-Version-Key`) so a page and its chunks come from
|
|
161
|
+
the same version. `none` keeps `index.html`
|
|
162
|
+
available by name. `adminShell(request, assets)` passes
|
|
145
163
|
paths relative to the Admin directory, so the app fetches them under `/admin/`:
|
|
146
164
|
`path => env.ASSETS.fetch(new Request(new URL("/admin/" + path, request.url), request))`. Admin assets are not a public database
|
|
147
165
|
API. PostgreSQL uses application-owned Hyperdrive/postgres.js wiring,
|
|
@@ -199,7 +217,10 @@ const reader = async (path: string) => (await assets('/' + path)) ?? new Respons
|
|
|
199
217
|
```
|
|
200
218
|
|
|
201
219
|
The reader returns a native `Response` or `null`, rejects traversal/control
|
|
202
|
-
characters and escaping symlinks
|
|
220
|
+
characters and escaping symlinks, sets `nosniff` on every file and caches Admin's
|
|
221
|
+
content-hashed `/assets/` chunks for a year (`immutable`); the app owns shell, CSP
|
|
222
|
+
and any other cache headers. Root it at Admin's directory: it caches every `/assets/` path for a year, so serve `public/` files with your own reader and cache headers. Bun has no edge in front of it: put a CDN or reverse
|
|
223
|
+
proxy before the server to keep static requests off it.
|
|
203
224
|
Use ignored `.env` on Bun.
|
|
204
225
|
|
|
205
226
|
Admin's build tools and UI packages are dependencies of `@aotter/mantle-admin`,
|
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
|
|
3
3
|
Use the [Worker quickstart](../start/quickstart-worker.md) for a complete native manifest and entry, [typed queries](../guides/typed-queries.md) for embedding, and [service composition](../cloudflare/service-entry.md) for optional surfaces.
|
|
4
4
|
|
|
5
|
-
The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.1/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
|
|
5
|
+
The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.2-alpha.1/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
|
|
@@ -45,6 +45,11 @@ than `["app"]` on an `appOnly` tool) fails when `mcp()` is constructed.
|
|
|
45
45
|
from clients that declare no MCP Apps support, as `appOnly` does for Views. It
|
|
46
46
|
is a listing hint only: `tools/call` still runs every identity, scope, `check`
|
|
47
47
|
and `authorize` decision, so an App-only write is authorized like any other.
|
|
48
|
+
It is not an access boundary either: any client holding the same credential can
|
|
49
|
+
call an App-only tool directly. An action that needs the person present is
|
|
50
|
+
enforced on the server, for example by a Procedure that accepts only a
|
|
51
|
+
single-use value which a person-present check in the browser produces and
|
|
52
|
+
which no MCP result ever returns.
|
|
48
53
|
|
|
49
54
|
A `run` may return `withResultMeta(data, meta)` from `@aotter/mantle/mcp`. The
|
|
50
55
|
result's text and `structuredContent` are built from `data` exactly as before;
|
|
@@ -68,6 +73,13 @@ const uploadTarget: Tool<unknown> = {
|
|
|
68
73
|
};
|
|
69
74
|
```
|
|
70
75
|
|
|
76
|
+
`run` also receives `{ apps }` after `handlers`: `apps` is true when the
|
|
77
|
+
`tools/call` request declares MCP Apps support and an App resource renders the
|
|
78
|
+
tool, so a tool can keep a secret in
|
|
79
|
+
the result `_meta` for an App and return it in the data for a client that has
|
|
80
|
+
none (`upload_media` does this with its targets). `callTool` and `callToolLazy`
|
|
81
|
+
take it as an optional last argument (default `{ apps: false }`).
|
|
82
|
+
|
|
71
83
|
`callTool` and `callToolLazy` return the wrapper itself, `{ data, _meta }`.
|
|
72
84
|
A caller that forwards a result to a model, such as an application's own agent,
|
|
73
85
|
must forward only `data` and drop `_meta`, or the signed URL leaks through that
|
|
@@ -135,24 +147,29 @@ file carries the `csp` your `defineAdmin({ mcp: { csp } })` declared; a loader m
|
|
|
135
147
|
`resources/read`; it replaces the resource's own `csp` rather than merging with it. A loader
|
|
136
148
|
that throws answers the host with a redacted error and logs the message on the server.
|
|
137
149
|
|
|
138
|
-
Resource URIs are
|
|
139
|
-
resource
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
the
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
Resource URIs are stable identities. The MCP Apps specification lets a host prefetch
|
|
151
|
+
and cache a UI resource by its URI, so by default Admin links its tools to the fixed
|
|
152
|
+
`ui://mantle-admin/admin`: after a deploy a host keeps the link it saved, and a new read
|
|
153
|
+
returns the new build. After the request passes the origin/identity/Admin gates, Admin
|
|
154
|
+
reads its built asset and also serves it at `ui://mantle-admin/admin/<digest>`, a hash of
|
|
155
|
+
the HTML, full static catalogue and material resource metadata, for conversations that
|
|
156
|
+
hold that URI. Workers cache only the raw asset and its identity per contributor and
|
|
157
|
+
ASSETS binding; caller-filtered catalogues are embedded per read. Bun rereads the file,
|
|
158
|
+
so a rebuild in the same process serves the new build. Run `mantle bundle` and deploy its
|
|
159
|
+
assets with the server; missing assets can fail discovery.
|
|
160
|
+
|
|
161
|
+
Change the URI only when saved conversations must not load the new App, because its
|
|
162
|
+
contract with its tools changed incompatibly: pass an explicit `uri` such as
|
|
163
|
+
`ui://mantle-admin/admin/v2`. A custom `html` function has no build identity to hash: it
|
|
164
|
+
is served at the fixed URI or an explicit `uri`.
|
|
151
165
|
|
|
152
166
|
### Keep old conversations working after an upgrade
|
|
153
167
|
|
|
154
|
-
|
|
155
|
-
|
|
168
|
+
With the fixed default, conversations keep rendering across deploys without retention.
|
|
169
|
+
A conversation that holds a content-hash URI from an earlier build (one started before
|
|
170
|
+
this default, or before an explicit `uri` change) needs that page
|
|
171
|
+
retained: a new URI does not invalidate a host's saved tool definitions or old
|
|
172
|
+
conversation cards. Keep previously deployed pages with `adminMcpApps({ catalogue, previous })`.
|
|
156
173
|
`previous` is an array of `AdminMcpSnapshot` from `@aotter/mantle-admin/mcp`:
|
|
157
174
|
`{ uri, html, csp?, domain?, title? }`. Each `html` includes the original `mantle-catalog`
|
|
158
175
|
script (the page returned by `resources/read`, not the raw bundle). The SDK serves
|
|
@@ -207,7 +224,9 @@ const previous = JSON.parse(readFileSync(path, 'utf8'));
|
|
|
207
224
|
const context = { env: {}, request: new Request('https://archive.invalid/mcp') };
|
|
208
225
|
const source = adminMcpApps({ ...appDefinition,
|
|
209
226
|
html: readFileSync('dist/client/admin/mcp-app.html', 'utf8') });
|
|
210
|
-
const { resources
|
|
227
|
+
const { resources } = typeof source === 'function' ? await source(context) : source;
|
|
228
|
+
// The build's own content-hash URI (an explicit `uri` contributes only that URI).
|
|
229
|
+
const resource = resources.find(r => /\/admin\/[a-f0-9]{64}$/.test(r.uri)) ?? resources[0];
|
|
211
230
|
const tools = new Set([...Object.keys(appDefinition.catalogue.views),
|
|
212
231
|
...Object.keys(appDefinition.catalogue.actions)]);
|
|
213
232
|
const loaded = typeof resource.html === 'string' ? resource.html
|
|
@@ -295,19 +314,76 @@ full screen, a row action, and a link. Check too that the host advertises
|
|
|
295
314
|
`hostCapabilities.serverTools`; without it the App is read-only. The automated interoperability test
|
|
296
315
|
uses the official `AppBridge`; your app runs its generated version ([Run the compatibility test](#run-the-compatibility-test)).
|
|
297
316
|
|
|
317
|
+
## The upload card
|
|
318
|
+
|
|
319
|
+
`upload_media` on `/mcp/admin` can open an upload card, an MCP App Admin ships
|
|
320
|
+
prebuilt. Register it beside the Admin App, with the same `media` function you
|
|
321
|
+
pass `mcp(...)`:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
import { adminMcpApps, mediaUploadCard } from '@aotter/mantle-admin/mcp';
|
|
325
|
+
import type { MediaStorage } from '@aotter/mantle/site';
|
|
326
|
+
import { mcp } from './generated/mcp.js';
|
|
327
|
+
import { mcpAppCatalogues } from './generated/admin-descriptors.js';
|
|
328
|
+
|
|
329
|
+
declare const options: Parameters<typeof mcp>[0];
|
|
330
|
+
declare function storage(env: unknown): MediaStorage; // e.g. r2MediaStorage(...)
|
|
331
|
+
|
|
332
|
+
const handler = mcp({ ...options, media: storage, apps: { admin: [
|
|
333
|
+
adminMcpApps({ catalogue: mcpAppCatalogues.admin }),
|
|
334
|
+
mediaUploadCard({ media: storage }),
|
|
335
|
+
] } });
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
The card is `ui://mantle-admin/upload` and renders `upload_media`. The person
|
|
339
|
+
picks an image (and may type alt text); the card reads the purposes with
|
|
340
|
+
`upload_media`, calls it with the file's metadata, PUTs the file straight to the
|
|
341
|
+
target with progress, calls `upload_media` again with the `uploadId`, and sends
|
|
342
|
+
`ui/message` with the asset id (or `ui/update-model-context` when the host takes
|
|
343
|
+
no messages). The bytes never pass through MCP or the host. The card uploads the
|
|
344
|
+
original file as the single variant a purpose takes
|
|
345
|
+
([ADR-0017](../../adr/0017-media-multi-variant-agent-side-optimization.md)); a
|
|
346
|
+
purpose with several variant slots needs an agent that prepares them, and the
|
|
347
|
+
card says so. It needs the host's `serverTools`.
|
|
348
|
+
|
|
349
|
+
The targets reach the card in the result `_meta` (`net.aotter.mantle/upload`)
|
|
350
|
+
when the request declares MCP Apps support and the card is registered;
|
|
351
|
+
otherwise they are in the result,
|
|
352
|
+
for an agent that PUTs them itself. The card reads either.
|
|
353
|
+
|
|
354
|
+
On `resources/read` the card declares the storage origin as
|
|
355
|
+
`_meta.ui.csp.connectDomains`. It is the storage's `uploadOrigin`:
|
|
356
|
+
`r2MediaStorage` sets it to the origin of its `endpoint`; a custom
|
|
357
|
+
`MediaStorage` sets it to the origin its `uploadUrl`s PUT to. Without it the
|
|
358
|
+
read fails with a logged error.
|
|
359
|
+
|
|
360
|
+
The bucket must answer the card's CORS preflight. Allow `PUT` with the
|
|
361
|
+
`Content-Type` header from the origin the host serves Apps from, and key the
|
|
362
|
+
rule on `Origin`: the request carries no cookies, and some clients (iOS) send no
|
|
363
|
+
`Referer`. A sandboxed frame without its own origin sends `Origin: null`. For
|
|
364
|
+
example, as R2 CORS JSON:
|
|
365
|
+
|
|
366
|
+
```json
|
|
367
|
+
[{ "AllowedOrigins": ["https://your-host-sandbox.example"], "AllowedMethods": ["PUT"], "AllowedHeaders": ["content-type"], "MaxAgeSeconds": 3600 }]
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Use the sandbox origin each host documents for its Apps (a host that honours
|
|
371
|
+
`_meta.ui.domain` serves the App from that origin; `mediaUploadCard` passes
|
|
372
|
+
`domain` through).
|
|
373
|
+
|
|
298
374
|
## Customising Admin for MCP hosts
|
|
299
375
|
|
|
300
376
|
`mantle bundle` builds the Admin MCP App from the same entry as the browser Admin
|
|
301
377
|
(`src/admin.tsx`, or the built-in one) into one self-contained file,
|
|
302
378
|
`<assets>/admin/mcp-app.html`, which `adminMcpApps` serves as
|
|
303
|
-
`ui://mantle-admin/admin
|
|
379
|
+
`ui://mantle-admin/admin` by default. Shared View cells and opted-in pages reach the host, with application plugins,
|
|
304
380
|
Tailwind classes and CSS. Browser-only record/input overrides do not mount there.
|
|
305
381
|
|
|
306
382
|
### Which slots render where
|
|
307
383
|
|
|
308
384
|
| Slot | Browser | MCP host |
|
|
309
385
|
| --- | --- | --- |
|
|
310
|
-
| `Shell`, `Sidebar`, `Header`, `Brand`, `Home`, `CollectionList`, `list.actions:<schema>` | yes | never: the App has no shell, sidebar or router |
|
|
386
|
+
| `Shell`, `Sidebar`, `Header`, `Brand`, `Home`, `CollectionList`, `SignIn`, `list.actions:<schema>` | yes | never: the App has no shell, sidebar or router |
|
|
311
387
|
| `cell:view:<view>.<column>` | yes | yes: each column of the rows the host's View tool returned |
|
|
312
388
|
| `cell:<schema>.<field>`, `input:<schema>.<field>`, `record.aside:<schema>`, `record.actions:<schema>`, `RecordView` | yes | never: browser-only record/collection slots; their `surface` is `"browser"` (`mantle bundle` reminds you) |
|
|
313
389
|
| `pages[]`, `nav`, `home`, nav badges | yes | only a page that declares `mcp` (below) |
|
|
@@ -227,6 +227,7 @@ collection's name, `<field>` one of its fields and `<column>` a column of an adm
|
|
|
227
227
|
| `Brand` | browser | `title`, `image?`, `href?` |
|
|
228
228
|
| `Home` | browser | `collections`, `site` (what the page shows; the built-in page loads its own data) |
|
|
229
229
|
| `CollectionList` | browser | `collection` (filters come from the page's location) |
|
|
230
|
+
| `SignIn` | browser | `methods`, `returnTo`, `oauthQuery`, `emailAutoComplete`, `onSignedIn(next)` ([below](#sign-in)) |
|
|
230
231
|
| `RecordView` | browser | `collection`, `entryId`, `surface` |
|
|
231
232
|
| `record.aside:<schema>` | browser | `collection`, `entry`, `surface` |
|
|
232
233
|
| `record.actions:<schema>` | browser | `collection`, `entry`, `surface` |
|
|
@@ -404,6 +405,34 @@ View that has an MCP tool (`tsc` checks it against the generated `admin-client.t
|
|
|
404
405
|
Authorization is unchanged by any of this. A component that shows or hides something grants or denies nothing; the server
|
|
405
406
|
checks every route, View and Procedure.
|
|
406
407
|
|
|
408
|
+
### Sign-in
|
|
409
|
+
|
|
410
|
+
`/admin/sign-in` lists the methods `authRoutes` returns from `GET <auth>/methods` (`email-otp`, `magic-link`, `passkey`,
|
|
411
|
+
`social`, `oauth`) through the `SignIn` slot. Admin renders the email code, magic link, social and OAuth methods; it
|
|
412
|
+
renders nothing for `passkey`, which is listed for an application that builds its own. Pass changed props to
|
|
413
|
+
`renderDefault` to reorder or drop `methods`, set the email field's `emailAutoComplete`, or wrap `onSignedIn`; render
|
|
414
|
+
your own elements around it to add a method of your own.
|
|
415
|
+
|
|
416
|
+
Admin calls `onSignedIn(next)` once the email code has signed in. `next` is where Admin goes by default: Better Auth's
|
|
417
|
+
OAuth authorize continuation when the sign-in came from an OAuth client such as an MCP connector, or the page's return
|
|
418
|
+
path. Run your own step first, then navigate to the same `next`. Redirecting methods (social, OAuth, magic link) leave
|
|
419
|
+
the page and do not call it.
|
|
420
|
+
|
|
421
|
+
```tsx
|
|
422
|
+
export default defineAdmin(admin, {
|
|
423
|
+
components: {
|
|
424
|
+
SignIn: (props) => props.renderDefault({ ...props,
|
|
425
|
+
onSignedIn: next => { sessionStorage.setItem("after-sign-in", next); window.location.assign("/admin/p/welcome"); } }),
|
|
426
|
+
},
|
|
427
|
+
pages: [{ path: "welcome", title: "Welcome", element: <Welcome /> }],
|
|
428
|
+
});
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Keep `next` out of the step page's URL: read it back from where your wrapper stored it, then navigate there unchanged.
|
|
432
|
+
A page that follows a `?next=` from its own address is an open redirect, and `next` may be Better Auth's absolute OAuth
|
|
433
|
+
continuation, so a "same-origin only" check would also break connector sign-in. The signed-in method stays locked once
|
|
434
|
+
`onSignedIn` runs, so navigate away (or replace the page) rather than leaving the card in place.
|
|
435
|
+
|
|
407
436
|
### Components that also render in an MCP host
|
|
408
437
|
|
|
409
438
|
Record/collection slots carry `surface: "browser"` and do not mount in an MCP
|
|
@@ -110,7 +110,11 @@ fast at a realistic volume, and no hot path scans a table that grows.
|
|
|
110
110
|
the largest tables at 100,000 rows or 10 times the expected first-year volume,
|
|
111
111
|
whichever is larger. Seed with SQL straight into that database, setting the
|
|
112
112
|
native columns as [reserved entry columns](../reference/schema.md#reserved-entry-columns)
|
|
113
|
-
describes: it is test data, not an application write.
|
|
113
|
+
describes: it is test data, not an application write. Seed the database the
|
|
114
|
+
app's local dev server uses, not the one a test harness creates and throws
|
|
115
|
+
away, and generate the rows inside the database in one short statement rather
|
|
116
|
+
than sending a long list of values: local D1 refuses a single statement over
|
|
117
|
+
about 100 KB. [Seed in bulk](../reference/schema.md#seed-in-bulk) shows both.
|
|
114
118
|
- **Load:** call every View and Procedure through the same callers and surfaces
|
|
115
119
|
the application uses, as each role. The app's local test harness already
|
|
116
120
|
starts it on a fresh database, signs each role in and calls routes and MCP
|
|
@@ -579,6 +579,6 @@ SEO notes:
|
|
|
579
579
|
|
|
580
580
|
The revisions are the content history: who may read them, switch and prune is the Procedures' `requires`. A record of
|
|
581
581
|
who changed what and when, across the whole app, is an observability concern rather than part of this content model;
|
|
582
|
-
it is planned outside the SDK, with
|
|
582
|
+
it is planned outside the SDK, with the hosting platform's observability or the app's own logging when self-hosted, and the
|
|
583
583
|
SDK's part is being discussed as a way to observe writes (aotter/mantle#1796). Until then, a revision's `author_id`
|
|
584
584
|
and `created_at` say who started it and when, and the switch handler is the place to log a switch.
|
|
@@ -257,6 +257,39 @@ check is "at least one", so a statement that writes several rows (`json_each(inp
|
|
|
257
257
|
passes; give an array input `minItems: 1` when an empty one is a mistake. A ref handler throws its own
|
|
258
258
|
`ContractError` instead.
|
|
259
259
|
|
|
260
|
+
## Change another member's row: inline author_id
|
|
261
|
+
|
|
262
|
+
A Procedure `restrict` narrows every statement in the program, including the ones after the first. With
|
|
263
|
+
`restrict: { registrations: "author_id = auth.uid()" }`, "cancel my seat and give it to the first person waiting"
|
|
264
|
+
cannot work: the promotion runs as the caller and sees no other member's row, so it changes nothing. Leave the
|
|
265
|
+
Procedure without `restrict` and pin the caller's own row inline, in every statement that reads it, with
|
|
266
|
+
`author_id = auth.uid()`; the statement that acts on other members' rows then runs unrestricted. Decide everything
|
|
267
|
+
that depends on the waiting list before the statement that changes it, and end with the own-row write that must
|
|
268
|
+
change exactly one row (`WHERE id = input.id`), so another member's id is `CONFLICT` and the whole program rolls back.
|
|
269
|
+
|
|
270
|
+
```sql
|
|
271
|
+
-- 1. Return the seat only if nobody waits for it.
|
|
272
|
+
UPDATE events SET seats_taken = seats_taken - 1
|
|
273
|
+
WHERE id = (SELECT r.eventId FROM registrations r WHERE r.id = input.id AND r.author_id = auth.uid() AND NOT r.waitlisted)
|
|
274
|
+
AND NOT EXISTS (SELECT 1 FROM registrations w WHERE w.eventId = events.id AND w.waitlisted)
|
|
275
|
+
RETURNING capacity - seats_taken AS seats_left;
|
|
276
|
+
-- 2. Otherwise the earliest waiter (another member's row) takes it.
|
|
277
|
+
UPDATE registrations SET waitlisted = false
|
|
278
|
+
WHERE id = (SELECT w.id FROM registrations w JOIN registrations r ON r.eventId = w.eventId
|
|
279
|
+
WHERE r.id = input.id AND r.author_id = auth.uid() AND NOT r.waitlisted AND w.waitlisted
|
|
280
|
+
ORDER BY w.created_at, w.id LIMIT 1)
|
|
281
|
+
RETURNING id AS promoted;
|
|
282
|
+
-- 3. The caller's own row: exactly one, or CONFLICT and nothing above stays.
|
|
283
|
+
DELETE FROM registrations WHERE id = input.id AND author_id = auth.uid() RETURNING id
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
A `= auth.uid()` term in the SQL is a filter like any other, not a policy: every statement that must stay on the
|
|
287
|
+
caller's rows needs its own. Rows another member waits with are their own inserts, so `author_id` names them; a
|
|
288
|
+
Procedure never inserts a row on someone else's behalf, since `author_id` is stamped from the caller. D1 runs the
|
|
289
|
+
program serialized. Under PostgreSQL's READ COMMITTED, two cancels for the same event at once can both pick the same
|
|
290
|
+
waiter and leave one seat counted as taken; measure that case before relying on it there.
|
|
291
|
+
[Club events](../../examples/club-events/README.md) has the whole waiting list (`cancel-registration`).
|
|
292
|
+
|
|
260
293
|
## A row with its children in one read
|
|
261
294
|
|
|
262
295
|
An admin, or an AI client, reads each product with its orders in one call instead of one call per product. A
|
|
@@ -17,7 +17,7 @@ requires:
|
|
|
17
17
|
| `ctx.admin` | Caller is in the admin layer |
|
|
18
18
|
| `{ ctx.auth.scope: "scope" }` | User scopes include that scope |
|
|
19
19
|
|
|
20
|
-
Selected REST, MCP and Admin boundaries accept an optional `authorize(target, caller)` callback. `Target`, exported by `@aotter/mantle/contracts`, contains `kind` (`view`, `procedure` or `schema`) and the manifest `name`; Admin generic entry routes also supply `action` (`read`, `create`, `update` or `delete`). The callback runs after credentials and `requires` pass, before execution and row policy. A false result or rejected promise returns `AUTH_DENIED` (HTTP 403); a synchronous exception is an internal error. MCP tool lists and Admin navigation metadata hide denied targets. Omitting the callback preserves existing behavior; direct generated typed functions do not invoke it.
|
|
20
|
+
Selected REST, MCP and Admin boundaries accept an optional `authorize(target, caller)` callback. `Target`, exported by `@aotter/mantle/contracts`, contains `kind` (`view`, `procedure` or `schema`) and the manifest `name`; Admin generic entry routes also supply `action` (`read`, `create`, `update` or `delete`). The callback runs after credentials and `requires` pass, before execution and row policy. A false result or rejected promise returns `AUTH_DENIED` (HTTP 403); a rejection with an `AUTH_DENIED` `ContractError` keeps its message (for example the role that may call the target), and a synchronous exception is an internal error. MCP tool lists and Admin navigation metadata hide denied targets. Omitting the callback preserves existing behavior; direct generated typed functions do not invoke it.
|
|
21
21
|
|
|
22
22
|
An impersonated session exposes `caller.impersonatedBy`, the real actor’s subject, only on the original branded Caller; applications decide impersonation policy in `authorize(target, caller)`.
|
|
23
23
|
|
|
@@ -91,6 +91,11 @@ and Admin routers for every route of the example apps:
|
|
|
91
91
|
limit the match to that endpoint's tools. Without a path, a name that the two
|
|
92
92
|
endpoints map to different targets matches nothing.
|
|
93
93
|
|
|
94
|
+
A host rule keyed on a request (for example "these targets need a step-up")
|
|
95
|
+
must key on the target `matchTarget` returns, or apply the same normalisation
|
|
96
|
+
itself. A rule on the raw pathname can be bypassed by an equivalent spelling
|
|
97
|
+
such as `/api/views/%6Frders` or a trailing slash.
|
|
98
|
+
|
|
94
99
|
Media tools (`admin:tool:<name>`) and the optional Admin developer and
|
|
95
100
|
statistics routes match even when the application does not mount them; the
|
|
96
101
|
application then answers `NOT_FOUND`. Routes and tools the application adds
|
|
@@ -168,6 +168,25 @@ their existing values.
|
|
|
168
168
|
| `published_at` (publishing) | integer microseconds when `status` is `'published'`, otherwise `NULL` | `timestamptz` or `NULL` | `NULL` |
|
|
169
169
|
| scope column (owner or field scope) | the field the `scope` names (`owner` in `owner = auth.uid()`), text, not null | the same | the insert fails |
|
|
170
170
|
|
|
171
|
+
### Seed in bulk
|
|
172
|
+
|
|
173
|
+
For a performance pass, seed the local database the dev server uses (`wrangler dev` on D1, your own PostgreSQL), not
|
|
174
|
+
the one a test harness starts and discards. Generate the rows in the database instead of sending each one: local D1
|
|
175
|
+
refuses a statement larger than about 100 KB (`SQLITE_TOOBIG`), so a long `INSERT … VALUES` list fails where one
|
|
176
|
+
generating statement does not. On SQLite and D1, a recursive CTE counts the rows:
|
|
177
|
+
|
|
178
|
+
```sql
|
|
179
|
+
-- seed.sql: 100,000 events, one hour apart (date-time fields are integer microseconds on SQLite and D1)
|
|
180
|
+
INSERT INTO events (id, version, created_at, updated_at, title, startsAt, capacity, seats_taken, closed)
|
|
181
|
+
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 100000)
|
|
182
|
+
SELECT 'seed-' || i, 1, 1767225600000000 + i, 1767225600000000 + i, 'Event ' || i,
|
|
183
|
+
(1767225600 + i * 3600) * 1000000, 50, 0, 0 FROM n;
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Apply it with `wrangler d1 execute DB --local --file seed.sql` (`DB` is the binding in `wrangler.jsonc`). On
|
|
187
|
+
PostgreSQL write `FROM generate_series(1, 100000) AS i` instead of the CTE, use `now() + i * interval '1 hour'` for the timestamps, and run
|
|
188
|
+
it with `psql -f seed.sql`. Seed related tables the same way, choosing parent ids by `i` (`'seed-' || (i % 5000 + 1)`).
|
|
189
|
+
|
|
171
190
|
Units differ by table: entry timestamps (`created_at`, `updated_at`, `published_at` and declared `date-time` fields)
|
|
172
191
|
are microseconds on SQLite and D1, while the optional media library's `media_assets.created_at` is epoch
|
|
173
192
|
**milliseconds** on every dialect ([media](../cloudflare/media-r2.md)). A declared `date` field is integer days since
|
|
@@ -4,6 +4,8 @@ Select `--site <module>` exporting `siteDefaults`, or pass the same options to p
|
|
|
4
4
|
|
|
5
5
|
Generation emits the generated site module, deployment DDL for `site_config`, `media_assets`, `pending_media_uploads`, and `site-seed.sql`. Apply initial schema only to an empty database. Apply seed SQL explicitly: only absent brand/title/description are seeded without overwriting edits. Origin, locales, icons and media purposes come from generated defaults. Runtime construction neither seeds nor migrates.
|
|
6
6
|
|
|
7
|
+
A media purpose may declare `visibility: "private"`; omitted, it is `"public"`.
|
|
8
|
+
|
|
7
9
|
Import the generated `site`, then use `site(db, verifiedCaller).read()` and `.updateSettings({ brand, title, description })`. Media is `site(db, caller).media(storage)`. Native SQLite/PostgreSQL are supported. Only selected modules enter the application bundle.
|
|
8
10
|
|
|
9
11
|
Admin site/media routes are optional capabilities; their additional admin-cookie gate and origin checks remain required. The app owns object storage, signing and upload CORS. See [media wiring](../cloudflare/media-r2.md).
|
|
@@ -215,6 +215,16 @@ by declared identity, scope and `check` requirements. Without `authorize`, the
|
|
|
215
215
|
complete endpoint catalogue is not a permission-filtered catalogue. Operation
|
|
216
216
|
validation, guards and credential checks still apply to calls.
|
|
217
217
|
|
|
218
|
+
With `authorize`, `discovery: "all"` keeps the caller-independent list: every
|
|
219
|
+
caller of the endpoint gets the same `tools/list` and the same tool names in
|
|
220
|
+
resource HTML, and `authorize` (with `requires`, scopes and `check`) decides at
|
|
221
|
+
call time. Prefer it when a caller's grant can change during a conversation, for
|
|
222
|
+
example a role switched in a directory: a host keeps the tool list it saved, and a
|
|
223
|
+
filtered list would leave it naming tools that are gone or missing ones the caller
|
|
224
|
+
now has. An `authorize` that rejects with `new ContractError("AUTH_DENIED", message)`
|
|
225
|
+
returns that message, so a refusal can name the role that may call the tool; a
|
|
226
|
+
plain `false` or any other rejection answers "Target is not authorized."
|
|
227
|
+
|
|
218
228
|
`toolsPageSize` pages `tools/list`: each page holds at most that many tools and
|
|
219
229
|
carries an opaque `nextCursor` while more remain; pass it back as `cursor`. A
|
|
220
230
|
cursor is valid only for the catalogue version that issued it; an unknown or
|
|
@@ -389,16 +399,62 @@ own entry; inherited property names never become titles or descriptions.
|
|
|
389
399
|
### Admin media MCP tools
|
|
390
400
|
|
|
391
401
|
With generated site configuration, `mcp({ db, caller, media: env => storage })`
|
|
392
|
-
loads the media module only on a configured admin MCP request and
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
+
loads the media module only on a configured admin MCP request and adds four
|
|
403
|
+
tools to `/mcp/admin` only. `storage` is the same `MediaStorage` used by the
|
|
404
|
+
Admin media library. Omitting `media` omits every media tool. Admins can invoke
|
|
405
|
+
them; cookie writes retain the origin check. Generated boundary code checks each
|
|
406
|
+
input and advertises read-only/destructive/open-world hints.
|
|
407
|
+
|
|
408
|
+
| Tool | Does |
|
|
409
|
+
|---|---|
|
|
410
|
+
| `upload_media` | Upload one image, in two calls (below). |
|
|
411
|
+
| `find_media` | Committed assets by `ids` (ids not found come back in `missing`), or newest first with `limit`, `cursor` and `search` (the exact id, or text in alt or caption). Read-only. |
|
|
412
|
+
| `update_media` | Set `alt` or `caption` of `id`; an omitted field is unchanged, an empty string clears it. |
|
|
413
|
+
| `delete_media` | Delete `id`'s objects and record (`destructiveHint`, so hosts confirm). A partial failure is retried with the same id. |
|
|
414
|
+
|
|
415
|
+
`upload_media` takes one mode per call:
|
|
416
|
+
|
|
417
|
+
1. `{ purpose, filename, variants: [{ mimeType, byteSize, role }], alt?, caption? }`
|
|
418
|
+
returns `{ uploadId, expiresAt, targets: [{ role, mimeType, method, url, headers }] }`.
|
|
419
|
+
PUT each variant's bytes to its `url` with its `headers`. Targets expire in
|
|
420
|
+
15 minutes and serve this one upload. When the request declares MCP Apps
|
|
421
|
+
support and a resource such as the upload card renders `upload_media`,
|
|
422
|
+
`targets` is left out of the result and sent only in the result
|
|
423
|
+
`_meta["net.aotter.mantle/upload"]`, which hosts give the App and not the
|
|
424
|
+
model; an agent without MCP Apps (a CLI agent such as Claude Code) reads them
|
|
425
|
+
in the result.
|
|
426
|
+
2. `{ uploadId, purpose?, alt?, caption? }` commits and returns the asset.
|
|
427
|
+
Store its id in fields that reference `media_assets.id`, never an upload URL.
|
|
428
|
+
An unknown or expired `uploadId` is `MEDIA_UPLOAD_EXPIRED` (make a new first
|
|
429
|
+
call); before the commit, a `purpose` other than the upload's is
|
|
430
|
+
`MEDIA_UPLOAD_MISMATCH`; a committed `uploadId` returns the same asset again
|
|
431
|
+
(the asset records no purpose).
|
|
432
|
+
|
|
433
|
+
`{ purpose? }` alone returns the purposes' policy, `{ purposes: [{ name, required, maxBytes }] }`
|
|
434
|
+
(each `required` slot lists its accepted MIME types, comma-separated); the
|
|
435
|
+
generated input schema also names the purposes and their caps, and policy
|
|
436
|
+
failures say what was expected. With the [upload card](../guides/mcp-apps.md#the-upload-card)
|
|
437
|
+
registered, an MCP Apps host shows it for this call.
|
|
438
|
+
|
|
439
|
+
`{ purpose, sourceUrl }` and `{ purpose, file: { download_url, file_id } }` have the
|
|
440
|
+
server fetch one image and run both steps in one call. `file` is the tool's
|
|
441
|
+
`openai/fileParams` argument, so a host that hands the tool a chat attachment
|
|
442
|
+
passes its download URL. Either takes a purpose with one variant slot. The fetch
|
|
443
|
+
accepts only `https` on the default port, without credentials, to a public host:
|
|
444
|
+
IPv6 literals, private, loopback, link-local, shared, reserved and multicast
|
|
445
|
+
IPv4 addresses, single-label names and names such as `localhost`, `*.local` and
|
|
446
|
+
`*.internal` are `MEDIA_SOURCE_REJECTED`, as is each redirect target (at most
|
|
447
|
+
three). The body is capped at the purpose's byte cap
|
|
448
|
+
(`MEDIA_VARIANT_SIZE_EXCEEDED`) and must sniff as PNG, JPEG, GIF, WebP or AVIF
|
|
449
|
+
(`MEDIA_MIME_REJECTED`); an unreachable source is `MEDIA_SOURCE_FAILED` and a
|
|
450
|
+
storage PUT failure `MEDIA_STORAGE_FAILED`. Names are checked, not resolved:
|
|
451
|
+
Workers cannot reach private addresses, and on another runtime restrict the
|
|
452
|
+
server's egress to the public internet. File content is never accepted as base64.
|
|
453
|
+
|
|
454
|
+
The 0.1 names `get_media_upload_policy`, `create_media_upload`,
|
|
455
|
+
`commit_media_upload`, `list_media_assets`, `get_media_asset`,
|
|
456
|
+
`update_media_asset` and `delete_media_asset` still work in 0.2, after the four,
|
|
457
|
+
with descriptions marked deprecated; they are removed in 0.3.
|
|
402
458
|
|
|
403
459
|
Generation always emits a `Handlers` type (empty when no Procedure uses `handler: { ref }`), so every app wires the
|
|
404
460
|
same way: `const handlers: Handlers = {...}`, then `store(db, caller, { handlers })`, `adminBindings({ handlers })`,
|