@akanjs/cli 3.0.0-alpha.100 → 3.0.0-alpha.101
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/.build-stamp
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
5ffeff9d981fb5e862b8dfe8607caec6785dc791950ba23d8338166ad32d1f62
|
|
@@ -621,9 +621,18 @@ it.
|
|
|
621
621
|
21-field model costs 12.6KB over eight entries; `mcp: { cru: false }` takes it to 4.7KB. `MCP catalogue: … ·
|
|
622
622
|
listing 214KB` plus a per-signal `MCP catalogue cost:` line says where it went.
|
|
623
623
|
- Settings live in the app's `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions,
|
|
624
|
-
allowedOrigins, pageSize, language, auth })`,
|
|
625
|
-
`AKAN_MCP_READONLY=true` is the read-only-deployment
|
|
626
|
-
whole surface off.
|
|
624
|
+
allowedOrigins, pageSize, language, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
|
|
625
|
+
`main.ts`**. Each field has an `AKAN_MCP_*` env spelling. `AKAN_MCP_READONLY=true` is the read-only-deployment
|
|
626
|
+
valve, not the exposure switch; `AKAN_MCP=false` takes the whole surface off.
|
|
627
|
+
- **Authentication is the same server.** An app mounting `libs/shared` serves the OAuth 2.1 authorization server
|
|
628
|
+
beside `/mcp` — `/.well-known/oauth-authorization-server`, `/oauth/authorize`, `/oauth/token`, `/oauth/register`
|
|
629
|
+
(RFC 7591; Cursor's `cursor://` redirect admitted) plus the consent page `libs/shared/page/oauth/consent` — and
|
|
630
|
+
names itself as the issuer, so `/mcp` demands a bearer token, verifies its signature, and refuses one carrying no
|
|
631
|
+
`aud`. A token is the app's own access JWT plus `aud`/`iss`/`client_id`, so `AccountMiddleware` and the guards
|
|
632
|
+
judge it unchanged; there is no scope. Per-app knobs live in `env.server.*` under `oauth` (`consentPath` and
|
|
633
|
+
`signinPath` with the basePath, `clients`, `dynamicRegistration`, `allowedRedirectSchemes`, `enabled`). `/mcp`
|
|
634
|
+
reads the `Authorization` header only — a cookie is dropped at the door — and `JWT_SECRET` is mandatory outside
|
|
635
|
+
`local`, because the derived fallback is forgeable from three public names.
|
|
627
636
|
- **The refusals are fail-closed**: a declared `mcp: false`, an endpoint with no `guards`, a mutation with no real
|
|
628
637
|
guard, `pubsub` and `message`, an `Any` or `Upload` return, a file upload, a required `Any` argument, and the
|
|
629
638
|
generated `light<Model>` read. A `prompt` also refuses a list argument and any `Any` argument. **Every refusal
|
|
@@ -746,10 +755,11 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
746
755
|
forms: a relation, an id with `ref`, or a polymorphic id with `refPath`. An array, a Map, `ref` together with
|
|
747
756
|
`refPath`, and a field naming no owner each fail the class build.
|
|
748
757
|
- **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time. The one
|
|
749
|
-
exception is opt-in and priced: `
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
758
|
+
exception is opt-in and priced: `cascade: "removeWithAny"` takes a free-form `String` type field holding the
|
|
759
|
+
owner's refName, and sweeps for children on **every** model's removal. The sweep is one indexed probe, but a
|
|
760
|
+
single wildcard edge turns every cascade in the app back to one document at a time; the boot log names the
|
|
761
|
+
edges in one `info` line. The action carries its own `refPath` in the option type, so the widening cannot be
|
|
762
|
+
declared apart from the direction it widens.
|
|
753
763
|
- **A cascade goes through the target's service, never its model** — that path is what runs the target's
|
|
754
764
|
`_postRemove`, where a module puts the side effect the removal has to carry.
|
|
755
765
|
- **Nothing checks whether another document still references the same target.** `File` in particular is deduped by
|
|
@@ -14,7 +14,17 @@
|
|
|
14
14
|
],
|
|
15
15
|
"update": {
|
|
16
16
|
"filePath": "./mcpRule.instruction.md",
|
|
17
|
-
"contents": [
|
|
17
|
+
"contents": [
|
|
18
|
+
"exposure rule",
|
|
19
|
+
"configuration",
|
|
20
|
+
"declaring endpoints",
|
|
21
|
+
"refusals and wire behaviour",
|
|
22
|
+
"prompts",
|
|
23
|
+
"rolling out to an app",
|
|
24
|
+
"choosing what to open",
|
|
25
|
+
"writing instructions",
|
|
26
|
+
"verifying an app"
|
|
27
|
+
],
|
|
18
28
|
"rules": [
|
|
19
29
|
"Use current Akan APIs only",
|
|
20
30
|
"State the rule before the reason, and keep the reason to one clause",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Akan MCP Exposure Guideline
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
|
-
Use this when writing a `*.signal.ts`, configuring `option.setMcp(...)`,
|
|
4
|
+
Use this when writing a `*.signal.ts`, configuring `option.setMcp(...)`, debugging a tool an agent cannot see, or
|
|
5
|
+
opening an app's service to agents end to end — the rollout checklist and the exposure design rules are at the end.
|
|
5
6
|
The always-loaded convention set carries the short version; this is the full contract.
|
|
6
7
|
|
|
7
8
|
## Exposure Rule
|
|
@@ -143,19 +144,23 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
|
|
|
143
144
|
clients read the structured half. A scalar return is unaffected: it has no structured half to point at, so it
|
|
144
145
|
ships as the value itself either way.
|
|
145
146
|
- A refused endpoint answers the *same* "unknown tool" as one that does not exist. Never make that
|
|
146
|
-
message more helpful — the difference is what enumerates your private surface.
|
|
147
|
-
the same way
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
message more helpful — the difference is what enumerates your private surface. Every 401 and 403 is generalized
|
|
148
|
+
the same way, the framework's `Access denied by guard: Admin` and an app's `No authentication with roles: admin`
|
|
149
|
+
alike: the caller reads `You are not permitted to perform this action.`, because either sentence names your
|
|
150
|
+
authorization structure to the one caller barred from it. A domain `Err` at any other status resolves through
|
|
151
|
+
the dictionary and keeps its own words, placeholders filled.
|
|
150
152
|
- The `readOnly` / `destructive` / `idempotent` hints a client renders are derived from the endpoint type and key
|
|
151
153
|
and are not configurable. Clients are told to distrust hints; they are never a gate.
|
|
152
154
|
- **`AKAN_MCP_READONLY=true` is the read-only-deployment valve, not the exposure switch.** It drops every mutation
|
|
153
155
|
whatever it declared, and reports each one in the boot log like any other refusal.
|
|
154
156
|
- OAuth resource metadata is published at `/.well-known/oauth-protected-resource` (and at that path plus the mount
|
|
155
|
-
path, the spelling most clients try first). `
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
path, the spelling most clients try first). `auth.authorizationServers` / `AKAN_MCP_AUTH_SERVERS`,
|
|
158
|
+
`auth.scopes` / `AKAN_MCP_SCOPES`, `auth.resource` / `AKAN_MCP_RESOURCE` and `auth.verify` configure it;
|
|
159
|
+
`insufficient_scope` is enforced only once scopes are declared. Naming an issuer changes three things at once: a
|
|
160
|
+
request with no credential is answered 401 from `initialize` on (an MCP client starts its OAuth flow on nothing
|
|
161
|
+
else), a token carrying no `aud` is refused (that issuer mints for its other resources too), and the challenge
|
|
162
|
+
carries no `error` code (RFC 6750 §3.1 reserves it for a credential that was presented). A server naming no
|
|
163
|
+
issuer keeps anonymous access and accepts a first-party token, which is bound by app and environment instead.
|
|
159
164
|
- **The boot log names every published entry with no dictionary `.desc()`.** An agent picks a tool by its
|
|
160
165
|
description, so a missing one is a broken tool. What the framework generates has no text of its own and borrows
|
|
161
166
|
the model's: the generated list reads the `.of()` label, and the base CRUD tools append the model's `.desc()` to
|
|
@@ -181,9 +186,13 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
|
|
|
181
186
|
body names one), and one that leaves a mirror out is refused just like one that contradicts the body: a gateway
|
|
182
187
|
rule keyed on a header never fires for the request that omitted it. Legacy requests are not checked. Capabilities
|
|
183
188
|
are derived from the catalogue, so a server with no prompts does not advertise `prompts`.
|
|
184
|
-
- **An expired
|
|
185
|
-
than that the tool does not exist.
|
|
186
|
-
|
|
189
|
+
- **An expired, wrongly-audienced or unverifiable bearer token is refused up front**, so an agent is told to
|
|
190
|
+
authenticate rather than that the tool does not exist. The signature is checked through `auth.verify`, which the
|
|
191
|
+
module that mints the tokens supplies (`libs/shared` does, from the app's signing secret); without a verifier the
|
|
192
|
+
claims are read unverified and a token signed wrong, like an opaque one, degrades to an anonymous caller.
|
|
193
|
+
- **`/mcp` reads the `Authorization` header and nothing else.** The `cookie` header is deleted from the request
|
|
194
|
+
before the account middleware runs, so a same-site page cannot drive `tools/call` on the visitor's ambient session
|
|
195
|
+
— the request class `CrossSiteGuard` shields every mutation from, which this route never passes through.
|
|
187
196
|
- **Resource URIs**: `akan://<model>/{id}`, `akan://<model>/list` for the model's own list, and
|
|
188
197
|
`akan://<model>/list/<sliceKey>` for a slice's. The root list takes no third segment on purpose — any token
|
|
189
198
|
there is one a slice could also be named. **Those three are the whole set**, so only the generated reads that
|
|
@@ -192,6 +201,47 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
|
|
|
192
201
|
- **The catalogue is one language**, `en` unless `language` says otherwise: it is built once at boot and cached by
|
|
193
202
|
clients, so there is no `Accept-Language` negotiation.
|
|
194
203
|
|
|
204
|
+
## Authentication — The Authorization Server Is The Same Process
|
|
205
|
+
|
|
206
|
+
`libs/shared/lib/_oauth` is an OAuth 2.1 authorization server (RFC 8414 metadata, PKCE `S256` only, RFC 7591
|
|
207
|
+
registration, Client ID Metadata Documents, RFC 9207 `iss`) served by the app that serves `/mcp`. Its `option.ts`
|
|
208
|
+
names the issuer and hands `McpAuth` a verifier, so mounting the lib is all an app does; the protocol engine it
|
|
209
|
+
delegates to lives in `akanjs/server` (`OAuthAuthorize`, `OAuthToken`, `OAuthRegistration`, `OAuthClientIdMetadata`,
|
|
210
|
+
`OAuthPkce`, `OAuthRedirect`, `OAuthMetadata`, `OAuthErrors`) and is unit-tested without a container.
|
|
211
|
+
|
|
212
|
+
- **Endpoints, at the origin's root:** `GET /.well-known/oauth-authorization-server`, `GET /oauth/authorize`,
|
|
213
|
+
`POST /oauth/token` (form-encoded), `POST /oauth/register`. The consent step is `libs/shared/page/oauth/consent`
|
|
214
|
+
— a server-rendered page whose two forms post to `/api/approveOAuthConsent/<requestId>` and
|
|
215
|
+
`/api/denyOAuthConsent/<requestId>` and follow the 302 back to the client. All of it is `mcp: false`.
|
|
216
|
+
- **The flow:** `POST /mcp` with no token → 401 with `resource_metadata` → the client reads the PRM, then the AS
|
|
217
|
+
metadata, registers (or uses a pre-registered / URL `client_id`), opens `/oauth/authorize` in a browser → the
|
|
218
|
+
server parks the request and redirects to the consent page (via `signinPath?redirect=` when anonymous) → the user
|
|
219
|
+
allows → `redirect_uri?code&state&iss` → the client posts the code and its PKCE verifier to `/oauth/token` → an
|
|
220
|
+
access token (1h, `aud` = the MCP resource, `iss`, `client_id`, `sub`, and the account's `self`/`me`) plus a
|
|
221
|
+
rotating refresh token (30d; reuse revokes the family). The token is the app's own access JWT, so
|
|
222
|
+
`AccountMiddleware` and every guard read it exactly as a browser session; nothing is scoped.
|
|
223
|
+
- **Invariants:** an authorization request lives ten minutes, binds to the first signed-in account that opens it,
|
|
224
|
+
and is decided once; a code lives sixty seconds and is consumed on first exchange even if that exchange fails; a
|
|
225
|
+
redirect URI must be registered and match exactly, loopback ports excepted (RFC 8252 §7.3); a refresh token is
|
|
226
|
+
bound to the client it was issued to. A `client_id` that is an HTTPS URL with a path is fetched as a metadata
|
|
227
|
+
document, never from the server's own network.
|
|
228
|
+
- **Per-app configuration** goes in `env.server.*` under `oauth`: `consentPath` and `signinPath` (with the basePath
|
|
229
|
+
when the app has one — `/office/oauth/consent`), `clients` (pre-registered, with an optional `clientSecret`),
|
|
230
|
+
`dynamicRegistration` (on by default: Claude Code and claude.ai register themselves), `allowedRedirectSchemes`
|
|
231
|
+
(`["cursor"]` by default, for Cursor's desktop callback), `accessTokenSeconds` (3600), `issuer` and `resource` (only when a tunnel or an edge
|
|
232
|
+
makes the derived origin wrong), `enabled: false` to take the server and the `/mcp` credential requirement away
|
|
233
|
+
together. The signin page must honour `?redirect=` to return the user to the consent page.
|
|
234
|
+
- **Clients:** Claude Code — `claude mcp add --transport http <name> <origin>/mcp` and nothing else; it registers,
|
|
235
|
+
opens the browser to `http://localhost:<port>/callback`, refreshes on 401. claude.ai — a custom connector by URL
|
|
236
|
+
(needs public HTTPS; DCR or the Advanced-settings client id/secret; callback
|
|
237
|
+
`https://claude.ai/api/mcp/auth_callback`). Cursor — the URL alone (DCR with a `cursor://` redirect), or a
|
|
238
|
+
pre-registered client in `mcp.json` `auth` with `http://localhost:8787/callback` /
|
|
239
|
+
`https://www.cursor.com/agents/mcp/oauth/callback` registered. A first-party browser token no longer opens `/mcp`
|
|
240
|
+
once the issuer is named; `--header "Authorization: Bearer …"` needs a token minted with `aud`.
|
|
241
|
+
- **`JWT_SECRET`** (or `security.jwtSecret`) is required outside `local`; the boot refuses without it, because the
|
|
242
|
+
derived fallback is seeded from the app, environment and repo names and forges an admin token for anyone who
|
|
243
|
+
knows them. `AKAN_ALLOW_DERIVED_JWT_SECRET=1` accepts that risk explicitly.
|
|
244
|
+
|
|
195
245
|
## `prompt()`
|
|
196
246
|
**`prompt()`** is invoked by the *user* — a client renders it as a slash command — not chosen by the model. `exec`
|
|
197
247
|
returns `PromptMessage[]`, or a bare string that is wrapped into one user message; build them with `Msg.user` /
|
|
@@ -215,3 +265,102 @@ over the ask.
|
|
|
215
265
|
frames down included, and is a no-op when nobody is streaming — so the same code runs unchanged over HTTP, a
|
|
216
266
|
websocket, and in tests. `McpProgress.streaming` says whether anyone is reading, for a report whose message
|
|
217
267
|
costs something to assemble.
|
|
268
|
+
|
|
269
|
+
## Rolling MCP Out To An App
|
|
270
|
+
|
|
271
|
+
The goal of exposing a service over MCP is that a person uses it through an agent instead of through the page:
|
|
272
|
+
one browser consent (the app's own sign-in included), then an hour-long access token and a thirty-day rotating
|
|
273
|
+
refresh token stand in for every later sign-in. The token is the app's own access JWT, so what an agent may do is
|
|
274
|
+
exactly what that person may do over HTTP — nothing is widened and nothing is scoped. Do these in order; steps 2
|
|
275
|
+
and 3 are the ones whose omission shows up as a 404 or a doubled basePath on the consent page.
|
|
276
|
+
|
|
277
|
+
1. **Mount the lib that carries the authorization server** (`libs/shared`) and confirm `lib/_oauth`,
|
|
278
|
+
`page/oauth/consent` and `srvkit/oauthServer.ts` are present. Its `option.ts` declares `auth` for `setMcp`, and
|
|
279
|
+
`setMcp` merges field by field in mount order with the app last — the app never restates `auth`.
|
|
280
|
+
2. **`akan.config.ts`: `syncPageLibs: true`** (or the lib's name in an array). `akan sync <app>` links the consent
|
|
281
|
+
route under every basePath the app declares; the link folder is generated and never committed.
|
|
282
|
+
3. **`env.server.*` → `oauth`.** `consentPath` / `signinPath` carry the basePath the browser sees
|
|
283
|
+
(`/office/oauth/consent`, `/office/signin`) and are omitted for an app without one. The server hands the
|
|
284
|
+
sign-in page `?redirect=` as the app's router names the route — basePath removed — because `router.push` puts
|
|
285
|
+
the locale and basePath back; the app adds nothing. `issuer` (`AKAN_MCP_AUTH_SERVERS` for the resource side)
|
|
286
|
+
only behind a tunnel or an edge that renames the host, `resource` (`AKAN_MCP_RESOURCE`) only when `path` moved
|
|
287
|
+
off `/mcp`, `clients` + `dynamicRegistration: false` only for a deployment that closes registration.
|
|
288
|
+
4. **The sign-in page honours `?redirect=`** by passing it unchanged to the password and SSO controls — a
|
|
289
|
+
router-space path, never prefixed by hand. Accepting it needs only `startsWith("/") && !startsWith("//")`.
|
|
290
|
+
5. **`lib/option.ts`: `option.setMcp({ instructions, legacyTextBlock: false })`** (`AKAN_MCP_INSTRUCTIONS`,
|
|
291
|
+
`AKAN_MCP_LEGACY_TEXT`). Leave `readOnly` (`AKAN_MCP_READONLY`) off when the point is to let people *use* the
|
|
292
|
+
service; it is the valve for a deployment that must not write, not the way to curate.
|
|
293
|
+
6. **`JWT_SECRET`** (or `security.jwtSecret`) in every deployment outside `local`; the boot refuses without it.
|
|
294
|
+
7. **Read the boot log** — `MCP catalogue: tools=… · listing …KB`, the `MCP catalogue cost:` line, one `warn` per
|
|
295
|
+
refusal, and the list of published entries with no `.desc()`. A tool missing from it is missing everywhere.
|
|
296
|
+
8. **Connect once per app** — `claude mcp add --transport http <name> <issuer>/mcp`; a second entry for the same
|
|
297
|
+
server with a pre-registered client tests a registration mode, it does not add anything a user needs.
|
|
298
|
+
|
|
299
|
+
## Choosing What To Open
|
|
300
|
+
|
|
301
|
+
One rule: **an endpoint is open to an agent when the same person could do the same thing from the page, under
|
|
302
|
+
the same guards.** What stays closed is closed because no agent has a reason to call it, not because of who may
|
|
303
|
+
— and that is the only thing `mcp: false` says.
|
|
304
|
+
|
|
305
|
+
| Endpoint | Decision |
|
|
306
|
+
|---|---|
|
|
307
|
+
| Domain read slices (`inOrg`, `inProject`, `inSelf`, `byStatuses`) | Open. A named slice publishes only when it declares its **own** `guards`; the `slice()` map reaches the root slice and the generated CRUD, never a named slice. A search slice only on data that may be enumerated. |
|
|
308
|
+
| The root slice and generated CRUD | Published by default; `root:` is `Admin`, so a user token never lists it. Take the cost line first: `mcp: { cru: false }` keeps the reads and drops the writes of a model whose write entries are most of its bytes; `mcp: false` for an internal or sensitive model. |
|
|
309
|
+
| Domain mutations (`startTask`, `completeTicket`) | Open when a person would ask an agent to do it for them; a resource guard is mandatory. An act that cannot be undone — signing, paying, sending, removing — either stays off or says so in its `.desc()`: MCP has no approval card. |
|
|
310
|
+
| Steps of a UI state machine (`requestPhoneCodeForSignin`, `setPasswordInPrepareUser`, a prepare-user flow) | `mcp: false`. Perfectly guarded, and only ever called by mistake. |
|
|
311
|
+
| Authentication, SSO, and the OAuth protocol endpoints | Already `mcp: false` where they are declared. |
|
|
312
|
+
| File upload, an `Any` / `Upload` return, a required `Any` argument, `pubsub` / `message`, `light<Model>` | Refused by the framework; nothing to write. |
|
|
313
|
+
| Admin-only endpoints | Leave the `Admin` guard; an account-scope guard hides the entry from every caller it refuses. |
|
|
314
|
+
|
|
315
|
+
- **Scope is what makes hiding possible.** `static scope: "account"` (a role check) is evaluated with no arguments
|
|
316
|
+
and filters the listing per caller; `"resource"` (`Can<Verb><Model>`, ownership) needs `context.getArg()`, stays
|
|
317
|
+
listed, and stops the call. Marking a resource guard `"account"` would make the listing lie — the marker has no
|
|
318
|
+
default for that reason.
|
|
319
|
+
- **Identity comes from the internal args** (`.with(Self)`, `.with(Account)`, `.with(Me)`), never from an id the
|
|
320
|
+
client sent, and the service re-checks ownership behind the guard.
|
|
321
|
+
- **An admin who signs in on the consent page mints an admin token.** A connector created under an admin account
|
|
322
|
+
passes `Admin` guards; treat that as an operational decision, and read the request's subject type when in doubt.
|
|
323
|
+
- **Return what answers the question.** A count-or-summary endpoint taking an id beats a document; a nullable model
|
|
324
|
+
return publishes no `outputSchema`, so prefer a non-null return plus an `Err`. Bulky page-only fields are
|
|
325
|
+
`field.visual`. Narrow a list with a named filter slice, never with the root list's raw `query`.
|
|
326
|
+
|
|
327
|
+
## Writing `instructions`
|
|
328
|
+
|
|
329
|
+
`*.abstract.md` and `<Agent.Guide>` never reach an MCP client. Exactly three texts do: `instructions`, every
|
|
330
|
+
published entry's `.desc()`, and a `prompt()`. All three are English, whatever the users speak — the reader is a
|
|
331
|
+
model, and the catalogue is built once in one `language` and cached by clients.
|
|
332
|
+
|
|
333
|
+
- **`instructions` is the workflow.** Five to eight sentences, in this order: what the app is and what its nouns
|
|
334
|
+
mean; **which tool to call first** and which ids the rest take; how a name becomes an id; what a write confirms
|
|
335
|
+
before it runs and which writes cannot be undone; what this server does *not* do and where that is done instead.
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
instructions: [
|
|
339
|
+
"Operations tools for a back-office that runs consulting projects.",
|
|
340
|
+
"An org is one company; a project belongs to an org; a ticket is one unit of work on a project.",
|
|
341
|
+
"Start from orgInSelf, then projectInOrg and ticketInProject; every other tool takes the ids those return.",
|
|
342
|
+
"Prefer ticketStatusInProject for 'how is it going' and ticketInProject only when the user asks for detail.",
|
|
343
|
+
"createTicket, startTicket and completeTicket write on the caller's behalf: confirm the project and title first.",
|
|
344
|
+
"Nothing here signs, invoices or uploads a file; those are deliberate acts on the record's own page.",
|
|
345
|
+
].join(" "),
|
|
346
|
+
```
|
|
347
|
+
- **`.desc()` is the reason to pick one tool** — what it returns and when to use it, two clauses. An id argument's
|
|
348
|
+
`.desc()` names the tool that returns it (`From projectInOrg`). The generated CRUD borrows the model's `.of()`
|
|
349
|
+
label and `.desc()`, which is the only text those entries can carry.
|
|
350
|
+
- **`prompt()` is a workflow the user invokes** as a slash command, not one the model chooses — a weekly report,
|
|
351
|
+
an onboarding check. Put the ask in `Msg.user` at a high `priority` and the attachments in `Msg.resource` masked
|
|
352
|
+
by their `Light` model at a low one.
|
|
353
|
+
|
|
354
|
+
## Verifying An App
|
|
355
|
+
|
|
356
|
+
- `akan lint <app>`, `akan typecheck <app>`, `akan sync <app>`, then the boot log above.
|
|
357
|
+
- The lib's own suites run wherever it is mounted: the OAuth signal test (metadata, registration, both `authorize`
|
|
358
|
+
redirects, code exchange, `/mcp` with the minted token and the refusal of a first-party or forged one, refresh
|
|
359
|
+
rotation) and the refresh-session and option unit tests.
|
|
360
|
+
- **One exposure test per app**, in a `lib/<model>.signal.test.ts`: list `tools/list` with a user token and pin the
|
|
361
|
+
names with `toEqual`, and assert the curated and admin-only ones with `not.toContain`. Exposure is derived from
|
|
362
|
+
the guards, so a guard change fails this test first — which is the point. The framework's own pattern is
|
|
363
|
+
`pkgs/akanjs/server/mcp/mcp.integration.test.ts`.
|
|
364
|
+
- Live: connect a client, consent, call a tool, `claude mcp logout`, authenticate again. `AKAN_PUBLIC_LOG_LEVEL=debug`
|
|
365
|
+
prints one `OAuth token request grant_type=… client_id=…` line per token call; two refreshes in the same second
|
|
366
|
+
both answering 200 is the grace window working, not a fault.
|
|
@@ -133,11 +133,13 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
133
133
|
`refPath`. An array, a Map, `ref` together with `refPath`, and a field naming no owner each fail the class build.
|
|
134
134
|
- **A `refPath` must name an `enumOf` field** — a free-form owner type is unknowable at build time, so every
|
|
135
135
|
model's removal would have to sweep the polymorphic table on the chance it is the owner.
|
|
136
|
-
- **`
|
|
137
|
-
owner is any model in the app, named by a free-form `String` holding the owner's refName.
|
|
136
|
+
- **`cascade: "removeWithAny"` buys that sweep on purpose**, for the one shape an enum cannot express: a child
|
|
137
|
+
whose owner is any model in the app, named by a free-form `String` holding the owner's refName. It is a third
|
|
138
|
+
action rather than a flag beside `removeWith`, because the widening and the direction are one decision — the
|
|
139
|
+
option type pairs the action with the `refPath` it reads the owner off, so neither can be declared alone.
|
|
138
140
|
|
|
139
141
|
```ts
|
|
140
|
-
parent: field(ID, { refPath: "parentType", cascade: "
|
|
142
|
+
parent: field(ID, { refPath: "parentType", cascade: "removeWithAny" }),
|
|
141
143
|
parentType: field(String), // holds the owner's refName
|
|
142
144
|
```
|
|
143
145
|
|
|
@@ -148,11 +150,12 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
148
150
|
`info` line naming the edges — that line is the answer to "why does this app remove everything per document".
|
|
149
151
|
The opt-in is what keeps an app that declared none on the fast path.
|
|
150
152
|
|
|
151
|
-
Refused at the class build: `
|
|
152
|
-
|
|
153
|
-
against that column, and a column that cannot hold one silently finds nothing), and the
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
Refused at the class build: `removeWithAny` on a `refPath` that already names an `enumOf` (the enum names the
|
|
154
|
+
candidates and indexes better — use `removeWith`), a `typeKey` that is not a `String` (the sweep matches a
|
|
155
|
+
refName against that column, and a column that cannot hold one silently finds nothing), and the action with no
|
|
156
|
+
`refPath` at all — that last one is a typecheck error at the call site, and the class build keeps the same
|
|
157
|
+
refusal for the macro and bundled paths that reach it untyped. A wildcard owner is also exempt from the mount
|
|
158
|
+
check that a monomorphic owner fails at boot — it names no module to mount.
|
|
156
159
|
- **A cascade goes through the target's service, never its model** — that path is what runs the target's
|
|
157
160
|
`_postRemove`, where a module puts the side effect the removal has to carry.
|
|
158
161
|
- **Bulk is decided at boot, per target model, for both directions.** A target with no `remove` schema hook, no
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/cli",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.101",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"@langchain/openai": "^1.4.6",
|
|
34
34
|
"@tailwindcss/node": "^4.3.0",
|
|
35
35
|
"@trapezedev/project": "^7.1.4",
|
|
36
|
-
"akanjs": "3.0.0-alpha.
|
|
36
|
+
"akanjs": "3.0.0-alpha.101",
|
|
37
37
|
"chalk": "^5.6.2",
|
|
38
38
|
"commander": "^14.0.3",
|
|
39
39
|
"dayjs": "^1.11.20",
|