@akanjs/cli 3.0.0-alpha.11 → 3.0.0-alpha.110
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 +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/{abstractCompactor-e5yq60qz.js → abstractCompactor-mjh5ppsq.js} +3 -5
- package/{agent.command-a3n15c6t.js → agent.command-d2gfxfck.js} +11 -13
- package/{application.command-rdm3et0g.js → application.command-pj2m7584.js} +65 -38
- package/{applicationBuildRunner-6m8n71w4.js → applicationBuildRunner-kxwbssgn.js} +27 -23
- package/{applicationReleasePackager-gg8ccbeh.js → applicationReleasePackager-4yasxwv9.js} +8 -9
- package/buildBatch.proc.js +12 -14
- package/capacitorApp-36rbr858.js +12 -0
- package/{cloud.command-56aeekqn.js → cloud.command-gxqp8xdv.js} +15 -18
- package/commandManifest.json +1 -1
- package/{context.command-ysxawe7h.js → context.command-ez8a0zyz.js} +19 -23
- package/dependencyScanner-shs82ynp.js +8 -0
- package/devTui-40ggmd99.js +553 -0
- package/devTuiView-c08ccbat.js +9 -0
- package/getDirname-dr3jz1xg.js +7 -0
- package/{guideline.command-yqf9a372.js → guideline.command-xmqwx8zm.js} +24 -22
- package/guidelines/agentRule/agentRule.generate.json +31 -0
- package/guidelines/agentRule/agentRule.instruction.md +531 -0
- package/guidelines/conventions/conventions.instruction.md +493 -369
- package/guidelines/framework/framework.generate.json +3 -2
- package/guidelines/framework/framework.instruction.md +0 -9
- package/guidelines/mcpRule/mcpRule.generate.json +35 -0
- package/guidelines/mcpRule/mcpRule.instruction.md +460 -0
- package/guidelines/modelStore/modelStore.instruction.md +12 -0
- package/guidelines/modelTemplate/modelTemplate.instruction.md +5 -0
- package/guidelines/queryRule/queryRule.generate.json +31 -0
- package/guidelines/queryRule/queryRule.instruction.md +178 -0
- package/guidelines/runtimeRule/runtimeRule.generate.json +25 -0
- package/guidelines/runtimeRule/runtimeRule.instruction.md +220 -0
- package/guidelines/ssrRule/ssrRule.instruction.md +51 -5
- package/guidelines/transportRule/transportRule.generate.json +31 -0
- package/guidelines/transportRule/transportRule.instruction.md +60 -0
- package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +54 -764
- package/guidelines/workspaceRecipes/workspaceRecipes.generate.json +33 -0
- package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +467 -0
- package/incrementalBuilder.proc.js +26 -25
- package/{index-8pd02prk.js → index-259dsnz3.js} +58 -25
- package/{index-yzsxc30b.js → index-2tv3fdx4.js} +975 -234
- package/index-3d9y066c.js +76 -0
- package/{index-76rn3g2c.js → index-47pj3aee.js} +4 -4
- package/{index-m90fn8xe.js → index-4edzf1gr.js} +32 -32
- package/{index-3789fdpg.js → index-600zhnve.js} +94 -61
- package/{index-1577bej2.js → index-6wwzs1bc.js} +2 -2
- package/{index-cv9cxs5k.js → index-7737gxcp.js} +18 -18
- package/{index-a5cs7d8r.js → index-8few9nyh.js} +13 -13
- package/{index-mewacxye.js → index-8pm30m73.js} +57 -57
- package/{index-wnp7hwq7.js → index-92zztqy1.js} +7 -7
- package/{index-fgc8r6dj.js → index-a5vmm6x4.js} +3 -1
- package/{index-cav1azma.js → index-a64esmtb.js} +1106 -1369
- package/{index-nwqqssnb.js → index-aj7tyh5w.js} +26 -23
- package/{index-xys926f2.js → index-by5gpp8x.js} +4 -4
- package/{index-yzmqvg23.js → index-fz5q3gd7.js} +8 -8
- package/{index-kcsa1zff.js → index-gyd220p2.js} +4 -4
- package/{index-j37qq1f2.js → index-h7yw2z9n.js} +7 -7
- package/{index-bjpxzr6s.js → index-hwkygn9s.js} +51 -3
- package/index-j9yhg404.js +159 -0
- package/{index-46tjzh6s.js → index-kcxmv48c.js} +29 -29
- package/{index-0cj2zxbm.js → index-kn1h7p1x.js} +12 -11
- package/{index-e4b2ae6b.js → index-ktb5njgy.js} +41 -34
- package/{index-etwh7sd3.js → index-mak3cd28.js} +100 -32
- package/{index-xjd3rqzj.js → index-n68jx79w.js} +6 -15
- package/{index-z1s5f4e0.js → index-ne2qy0nk.js} +36 -37
- package/{index-65aeh789.js → index-pnqbkf5p.js} +1 -1
- package/{index-67546d0j.js → index-q0y2jq9j.js} +14 -2
- package/{index-4mgkgba3.js → index-rwg2aa4f.js} +14 -14
- package/{index-bzznxgcx.js → index-v0qh106m.js} +1002 -128
- package/{index-j9cxndc0.js → index-w8kmx1b6.js} +20 -11
- package/{index-h5whaj26.js → index-ws5e0j51.js} +16 -12
- package/{index-9va8zayb.js → index-wt7qrtd4.js} +27 -27
- package/index.js +22 -25
- package/{library.command-x6t29a30.js → library.command-nbsky672.js} +11 -10
- package/{localRegistry.command-649g1n7b.js → localRegistry.command-ts6t5e5a.js} +20 -23
- package/{module.command-8w8ys5t3.js → module.command-29vqzrh8.js} +13 -16
- package/{package.command-zgy7f3fz.js → package.command-axv0abmn.js} +8 -10
- package/package.json +5 -7
- package/{page.command-yjmnkyk2.js → page.command-vs9s3zrh.js} +7 -9
- package/{primitive.command-gfqaagsg.js → primitive.command-cbxmqbtc.js} +13 -16
- package/{quality.command-ec8r1dn3.js → quality.command-gmbs7nws.js} +184 -334
- package/recipeScanner-fpagtgc1.js +11 -0
- package/{repair.command-p9ey6mkm.js → repair.command-bqsjptsj.js} +12 -14
- package/{routeSourceValidator-e4jmbfcx.js → routeSourceValidator-apyqw7wk.js} +90 -19
- package/{scalar.command-9dhmqhtm.js → scalar.command-6k1vhe9s.js} +11 -14
- package/subspace.command-35a3t6vv.js +755 -0
- package/templates/app/env/env.client.debug.ts.template +1 -5
- package/templates/app/env/env.client.develop.ts.template +1 -5
- package/templates/app/env/env.client.local.ts.template +1 -5
- package/templates/app/env/env.client.main.ts.template +1 -5
- package/templates/app/env/env.client.testing.ts.template +1 -5
- package/templates/app/env/env.client.type.ts +2 -6
- package/templates/app/env/env.server.debug.ts.template +1 -5
- package/templates/app/env/env.server.develop.ts.template +1 -5
- package/templates/app/env/env.server.local.ts.template +1 -5
- package/templates/app/env/env.server.main.ts.template +1 -5
- package/templates/app/main.ts +3 -1
- package/templates/app/page/_index.tsx +3 -3
- package/templates/app/page/_layout.tsx +31 -32
- package/templates/appSample/lib/task/task.signal.ts +11 -6
- package/templates/appSample/page/task/[taskId]/_index.tsx +22 -27
- package/templates/appSample/page/task/[taskId]/edit.tsx +25 -30
- package/templates/appSample/page/task/_index.tsx +3 -7
- package/templates/appSample/page/task/_layout.tsx +4 -15
- package/templates/appSample/page/task/new.tsx +4 -10
- package/templates/appSample/srvkit/AuthGuard.ts +2 -2
- package/templates/crudPages/[__model__Id]/edit/page.tsx +27 -29
- package/templates/crudPages/[__model__Id]/page.tsx +24 -32
- package/templates/crudPages/new/page.tsx +26 -25
- package/templates/crudPages/page.tsx +27 -26
- package/templates/crudSinglePage/page.tsx +26 -21
- package/templates/env/_env.server.type.ts +7 -5
- package/templates/lib/db.ts +1 -1
- package/templates/lib/dict.ts +1 -1
- package/templates/lib/sig.ts +5 -1
- package/templates/lib/srv.ts +6 -2
- package/templates/module/__model__.dictionary.ts +4 -1
- package/templates/module/__model__.signal.ts +2 -3
- package/templates/module/__model__.store.ts +1 -2
- package/templates/workspaceRoot/.gitignore.template +0 -1
- package/templates/workspaceRoot/.husky/pre-commit.template +16 -0
- package/templates/workspaceRoot/.vscode/settings.json.template +1 -1
- package/templates/workspaceRoot/AGENTS.md.template +4 -0
- package/templates/workspaceRoot/CLAUDE.md.template +19 -0
- package/templates/workspaceRoot/biome.json.template +1 -1
- package/templates/workspaceRoot/docs/AI-DEVELOPMENT.md.template +10 -14
- package/templates/workspaceRoot/docs/GENERATED.md.template +12 -10
- package/templates/workspaceRoot/package.json.template +2 -4
- package/typeChecker-3xgrzc2v.js +7 -0
- package/typecheck.proc.js +4 -5
- package/{workflow.command-ycwc6zr6.js → workflow.command-nwpcdc8p.js} +22 -25
- package/{workspace.command-xhyhajxa.js → workspace.command-aczjxzbf.js} +64 -66
- package/capacitorApp-mr81qdxq.js +0 -61
- package/dependencyScanner-f6grrep3.js +0 -9
- package/getDirname-m9s0cmhq.js +0 -8
- package/index-mxvakhsm.js +0 -66
- package/index-r24hmh0q.js +0 -4
- package/recipeScanner-tghkzrrt.js +0 -12
- package/typeChecker-kravn7ns.js +0 -8
- /package/{index-0wae5ebk.js → index-meh558jy.js} +0 -0
- /package/{index-ss469dec.js → index-nz8prx9g.js} +0 -0
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
],
|
|
21
21
|
"update": {
|
|
22
22
|
"filePath": "./framework.instruction.md",
|
|
23
|
-
"contents": ["
|
|
23
|
+
"contents": ["file ownership", "current API patterns", "codegen rules", "theming and UI customization"],
|
|
24
24
|
"rules": [
|
|
25
25
|
"Use current Akan APIs only",
|
|
26
26
|
"Keep output parseable for codegen",
|
|
27
|
-
"Include ownership boundaries
|
|
27
|
+
"Include ownership boundaries",
|
|
28
|
+
"Carry no meta about this document itself — it renders into every workspace's always-loaded AGENTS.md"
|
|
28
29
|
]
|
|
29
30
|
},
|
|
30
31
|
"page": "apps/akan/page/(docs)/docs/intro/fundamentals.tsx"
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# Akan.js Framework Guide
|
|
2
2
|
|
|
3
|
-
## Purpose
|
|
4
|
-
Use this as the compact framework context for AI codegen. It should explain how Akan turns convention-based files into a full-stack app without teaching every API in one place.
|
|
5
|
-
|
|
6
3
|
## Ownership
|
|
7
4
|
- `apps/<app>` contains app code, pages, env files, module folders, UI, webkit, srvkit, and common utilities.
|
|
8
5
|
- `libs/<lib>` contains reusable domain and utility libraries consumed by apps.
|
|
@@ -28,9 +25,3 @@ When a request implies a distinct look and feel, do not stop at colors — custo
|
|
|
28
25
|
- **Theme (`apps/<app>/page/styles.css`).** The app imports Tailwind and `akanjs/ui/styles.css`, then overrides semantic token *values* per theme under `:root, [data-theme="dark"]` and `[data-theme="light"]` (`--background`, `--foreground`, `--primary`, `--muted`, `--border`, … each with a `-foreground` pair for text). The framework maps them to Tailwind color names, so `bg-primary` / `text-foreground` follow the `data-theme` attribute; corner rounding uses `--radius-box` / `--radius-field`. Fetch `get_guideline` with `cssRule` for the full token set before a deep theme pass.
|
|
29
26
|
- **Lib tokens (`libs/<lib>/ui/tokens.css`).** Colors a lib's own components pin — a vendor brand color, a fixed surface — are declared once there as plain `:root` custom properties and compiled into every app that reaches the lib, ahead of the app's stylesheets. Reference them as `bg-[var(--kakao)]`; never copy the block into each app.
|
|
30
27
|
- **Components (`page/**/_overrides.tsx`).** When a default `akanjs/ui` component (Button, Modal, Table, Input, Select, …) is too restrictive for the design, re-skin it per route instead of forking, wrapping, or fighting it with utility classes. Write a drop-in replacement in `apps/<app>/ui/` typed against the slot contract (`AkanModalComponent`, or `AkanUiOverrides["<Slot>"]`), composing the framework's headless parts, then bind it in a `page/**/_overrides.tsx` manifest with a single `export default override({ Slot: BrandComponent })`. Overrides cascade down the route tree like layouts (closest ancestor wins). Fetch `get_guideline` with `componentRule` and read the `references/ui/customize` docs page for the slot list and patterns.
|
|
31
|
-
|
|
32
|
-
## Review Checklist
|
|
33
|
-
- The instruction points to current docs pages, not removed docs routes.
|
|
34
|
-
- Generated examples use current Akan builder APIs and scanner-friendly filenames.
|
|
35
|
-
- The output contract tells the model which file paths to return.
|
|
36
|
-
- The guide avoids broad framework essays when a concrete file rule is better.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "MCP Exposure Rule",
|
|
3
|
+
"description": "How Akan signals reach AI agents over MCP — guard-derived exposure, refusals, prompts, and resources",
|
|
4
|
+
"group": "core",
|
|
5
|
+
"codegenPriority": 4,
|
|
6
|
+
"deprecated": false,
|
|
7
|
+
"scans": [
|
|
8
|
+
{
|
|
9
|
+
"type": "example",
|
|
10
|
+
"description": "Signal files whose slices and endpoints declare the guards MCP exposure is derived from",
|
|
11
|
+
"path": "{apps,libs}/*/lib/*/*.signal.ts",
|
|
12
|
+
"sample": 4
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"update": {
|
|
16
|
+
"filePath": "./mcpRule.instruction.md",
|
|
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
|
+
],
|
|
28
|
+
"rules": [
|
|
29
|
+
"Use current Akan APIs only",
|
|
30
|
+
"State the rule before the reason, and keep the reason to one clause",
|
|
31
|
+
"Name the env spelling beside every option field",
|
|
32
|
+
"Keep it workspace-neutral — no app or repo names"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# Akan MCP Exposure Guideline
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
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.
|
|
6
|
+
The always-loaded convention set carries the short version; this is the full contract.
|
|
7
|
+
|
|
8
|
+
## Exposure Rule
|
|
9
|
+
|
|
10
|
+
Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
|
|
11
|
+
follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An endpoint
|
|
12
|
+
that declares a real guard is published; one that declares none is refused, and so is a mutation whose only guard is
|
|
13
|
+
`Public`. `AKAN_MCP=false` takes the whole surface off. The reasoning is that the guards are already the
|
|
14
|
+
authorization decision and `filterForAccount` re-reads them per caller on every listing, so a second per-endpoint
|
|
15
|
+
switch says nothing the guards do not — while guaranteeing that every endpoint added later is invisible to agents
|
|
16
|
+
until somebody remembers it.
|
|
17
|
+
|
|
18
|
+
## Keeping An Endpoint Off The Shelf — `mcp: false`
|
|
19
|
+
|
|
20
|
+
Guards answer "may an agent call this". They do not answer "does this belong on a shelf", and those are different
|
|
21
|
+
questions: a step of a UI-driven state machine (`requestPhoneCodeForSignin`, `setPasswordInPrepareUser`) is
|
|
22
|
+
perfectly guarded, perfectly callable, and a model would only ever call it by mistake. **`mcp: false` is that
|
|
23
|
+
second answer, and it is an opt-*out*: everything still publishes by default, so an endpoint added later is
|
|
24
|
+
visible without anyone remembering it.**
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
requestPhoneCodeForSignin: mutation(Boolean, { guards: [Every], mcp: false }),
|
|
28
|
+
inTodo: init({ guards: [SignedIn], mcp: false }), // the slice's list and insight together
|
|
29
|
+
export class FileSlice extends slice(srv.file, {
|
|
30
|
+
guards: { root: Admin, get: SignedIn, cru: SignedIn },
|
|
31
|
+
mcp: { cru: false }, // reads yes, generated writes no
|
|
32
|
+
}) {}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- **It is curation, not authorization.** HTTP serves the endpoint exactly as before and the guards are still the
|
|
36
|
+
only thing deciding who may call it. Never write `mcp: false` where you meant a guard.
|
|
37
|
+
- **The `mcp` map on `slice()` mirrors the `guards` map above it** — same keys (`root`, `get`, `cru`, `create`,
|
|
38
|
+
`update`, `remove`), same fallbacks (`create`/`update`/`remove` inherit `cru`), and the same scope: it narrows
|
|
39
|
+
the root slice and the generated CRUD, and never reaches a named slice or a custom endpoint. Those declare
|
|
40
|
+
their own `mcp` beside their own `guards`. The bare `mcp: false` is every key at once, still within that scope.
|
|
41
|
+
- **`mcp: true` is the default and is a no-op**; write it only to say out loud that an entry is deliberately on
|
|
42
|
+
the shelf. It does not override a shape refusal (an `Any` return stays refused) and it cannot publish something
|
|
43
|
+
the guard rules keep out.
|
|
44
|
+
- **Reach for it by cost, not by taste.** Every entry inlines the schema of every model it mentions, so a plain
|
|
45
|
+
21-field model with one named slice costs 12.6KB across its eight entries. `mcp: { cru: false }` on that model
|
|
46
|
+
takes it to 4.7KB — the write entries carry both the input *and* the full model, and are 63% of it. Those figures
|
|
47
|
+
are for `outputSchema: "full"`; the default names nested models instead of inlining them (below). Measured on a
|
|
48
|
+
fully open 347-tool catalogue: 1194KB before, 532KB under the default, 194KB under `"none"`, and 1046KB even under
|
|
49
|
+
`"full"` — the id and nullable savings apply in every mode — before any endpoint was curated.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
|
|
53
|
+
`version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`, and `auth`. **Not `main.ts`**: the gateway
|
|
54
|
+
there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
|
|
55
|
+
mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
|
|
56
|
+
declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
|
|
57
|
+
`AKAN_MCP_PATH`, `AKAN_MCP_VERSION`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`, `AKAN_MCP_PAGE_SIZE`,
|
|
58
|
+
`AKAN_MCP_LANGUAGE`, `AKAN_MCP_LEGACY_TEXT`, `AKAN_MCP_OUTPUT_SCHEMA`, `AKAN_MCP_RATE_LIMIT`, `AKAN_MCP_CONCURRENT`,
|
|
59
|
+
`AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a deployment that configures what the source does
|
|
60
|
+
not, which the option overrides.
|
|
61
|
+
The two booleans answer to `AKAN_PUBLIC_MCP` / `AKAN_PUBLIC_MCP_READONLY` too, the same pairing `AKAN_OPENAPI`
|
|
62
|
+
has, and a value written in code wins over the env of the same name — an explicit `undefined` is not a value.
|
|
63
|
+
`AKAN_MCP_PATH` is normalized to a leading `/`, because the route key and the OAuth metadata path are both built by
|
|
64
|
+
concatenation.
|
|
65
|
+
|
|
66
|
+
## Declaring Endpoints
|
|
67
|
+
```typescript
|
|
68
|
+
// apps/<app>/lib/option.ts
|
|
69
|
+
export const option = new AkanOption<ModulesOptions>().setMcp({
|
|
70
|
+
instructions: "Domain tools for this app. Start from taskInTodo.",
|
|
71
|
+
language: "en",
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// <model>.signal.ts — every one of these is an MCP tool, with no `mcp:` option anywhere
|
|
77
|
+
export class TaskSlice extends slice(
|
|
78
|
+
srv.task,
|
|
79
|
+
{ guards: { root: Admin, get: SignedIn, cru: SignedIn } },
|
|
80
|
+
(init) => ({
|
|
81
|
+
// its own guards: the map above reaches base CRUD and the root slice, never a named slice — so a named slice
|
|
82
|
+
// that names none is refused rather than published, which is the one shape to watch for.
|
|
83
|
+
inTodo: init({ guards: [SignedIn] }).exec(function () {
|
|
84
|
+
return this.taskService.queryByStatuses(["todo"]);
|
|
85
|
+
}),
|
|
86
|
+
}),
|
|
87
|
+
) {}
|
|
88
|
+
|
|
89
|
+
export class TaskEndpoint extends endpoint(srv.task, ({ mutation }) => ({
|
|
90
|
+
startTask: mutation(cnst.Task, { guards: [SignedIn] })
|
|
91
|
+
.param("taskId", ID)
|
|
92
|
+
.exec(async function (taskId) {
|
|
93
|
+
return await this.taskService.startTask(taskId);
|
|
94
|
+
}),
|
|
95
|
+
})) {}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
// page/task/[taskId]/_index.tsx — the screen, published to agents as the prompt `reviewTask`
|
|
100
|
+
export default page()
|
|
101
|
+
.param("taskId", ID, { desc: "The task to review." })
|
|
102
|
+
.prompt("reviewTask", "Review this task and suggest next steps.")
|
|
103
|
+
.render(async ({ taskId }) => {
|
|
104
|
+
const { taskView } = await fetch.viewTask(taskId);
|
|
105
|
+
return <Task.Zone.View view={taskView} />;
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Refusals And Wire Behaviour
|
|
110
|
+
- **`mcp: false` is read first**, because it is the one answer nobody had to derive — somebody stated it. Its
|
|
111
|
+
reason says "HTTP still serves it", because that is the sentence that stops the next reader treating it as a lock.
|
|
112
|
+
- **The refusals are fail-closed**: **an endpoint that declares no `guards` at all** (nobody decided who may reach
|
|
113
|
+
it), **a mutation with no real `guards`** (`[Public]` is having none, spelled out — it answers true
|
|
114
|
+
unconditionally), `pubsub` and `message` (their internal args read a socket an MCP request does not have), an
|
|
115
|
+
`Any` or `Upload` return, a file upload, **an argument typed `Any` that must be filled**, and the generated
|
|
116
|
+
**`light<Model>` read** — it returns the same document as `<Model>` under the same guards, in a shape trimmed
|
|
117
|
+
for a page's payload, so publishing both put two entries per model in a listing an agent pays for every turn
|
|
118
|
+
and gave it nothing but the word "light" to choose between them.
|
|
119
|
+
- **The boot log sizes the catalogue and says which signals it went to** — `MCP catalogue: tools=48 … · listing
|
|
120
|
+
214KB` and `MCP catalogue cost: user 27/71KB · file 6/22KB · …`, heaviest first. Read it before narrowing
|
|
121
|
+
anything: the number is nobody's intuition, because MCP has no shared component section and forbids a `$ref`
|
|
122
|
+
across entries, so **every entry inlines the full schema of every model it mentions** and a listing is re-sent
|
|
123
|
+
whole to every agent that connects, before its first turn. Past 100KB it says so as a `warn`.
|
|
124
|
+
- **Every refusal is named in the boot log**: one `warn` per endpoint plus a `MCP catalogue: tools=…
|
|
125
|
+
resourceTemplates=…` count, and a `MCP page prompts: N (…)` line once the RSC worker has listed the pages. Read that line first when a tool you expected is missing — and it is the *only* place the answer exists,
|
|
126
|
+
because there is no absent opt-in to notice. The API explorer badges the same rule per endpoint (`MCP` /
|
|
127
|
+
`MCP refused`), from the same shared implementation the catalogue runs.
|
|
128
|
+
- **An `Any` argument is left out of the published schema** rather than described as `{}` — it tells a model
|
|
129
|
+
nothing — and a value sent for one is refused by name, so the endpoint reads it as omitted. That is what happens
|
|
130
|
+
to the root list's raw `query` descriptor: read as sent, it would be an arbitrary filter over every model you
|
|
131
|
+
publish. Declare a named filter slice when an agent should narrow a list.
|
|
132
|
+
- **A nullable model return publishes no `outputSchema`**, and its empty answer ships as the text `null` with no
|
|
133
|
+
`structuredContent`. That field is an object by definition, so `null` cannot ride in it any more than an array
|
|
134
|
+
can — a list is wrapped as `{ items: … }` for the same reason — and a declared schema obliges every result to
|
|
135
|
+
match it, so a client SDK throws on the first call that finds nothing. A nullable *list* keeps its schema, and a
|
|
136
|
+
scalar return has no structured half at all: it ships as the value itself, not as JSON.
|
|
137
|
+
- **An `outputSchema` names no `hidden`, `secret`, or `visual` field.** Every response has the first two stripped,
|
|
138
|
+
so publishing them promises a property no answer can carry — and on a model like `user` the names are the leak.
|
|
139
|
+
Your *input* schema keeps all three: they are legal to send, and the same model describes a request body.
|
|
140
|
+
- **A `field.visual` is stripped from every MCP result.** It is the marker for a field the page renders and no
|
|
141
|
+
question is answered from — a blur placeholder, a rendered HTML body — and it is cost rather than secrecy, so
|
|
142
|
+
nothing is refused over one. The strip happens in the MCP dispatcher rather than in `resolveReturn`, which every
|
|
143
|
+
ordinary HTTP response also passes through: the whole point is that a browser still receives it. That is also why
|
|
144
|
+
the readable schema drops it — a non-optional visual field would otherwise be listed `required` and a validating
|
|
145
|
+
client would refuse a result that correctly omits it.
|
|
146
|
+
- **A structured result ships twice by default**, once as `structuredContent` and once as the same JSON in the text
|
|
147
|
+
block, which is what the spec asks of a server for clients predating the structured field. It is also a flat
|
|
148
|
+
doubling of what every model-returning tool costs a model. `option.setMcp({ legacyTextBlock: false })` — or
|
|
149
|
+
`AKAN_MCP_LEGACY_TEXT=false` — leaves a one-line pointer in the text block instead, for a deployment whose
|
|
150
|
+
clients read the structured half. A scalar return is unaffected: it has no structured half to point at, so it
|
|
151
|
+
ships as the value itself either way. `resources/read` is unaffected too: `ReadResourceResult` has no
|
|
152
|
+
`structuredContent`, so a resource's text is its only channel and always carries the serialized value.
|
|
153
|
+
- **A request schema asks for a relation's id, and a response schema names a nested model.** `serialize` sends a
|
|
154
|
+
relation field as its id and the server never converts an object back, so publishing the related model's shape
|
|
155
|
+
asked an agent for what the document layer cannot take. On the response side every entry inlines the transitive
|
|
156
|
+
closure of every model it mentions (a `$ref` may not cross entries), and on an open catalogue 83% of the bytes
|
|
157
|
+
were the same schemas repeated — so a nested database model is published as `{ type: "object", description:
|
|
158
|
+
"<ModelName>" }` and the returned model keeps every field of its own; an embedded scalar stays inline. Measured,
|
|
159
|
+
that took a 347-tool listing from 1194KB to 532KB, and one user's paged `tools/list` to 320KB.
|
|
160
|
+
`option.setMcp({ outputSchema: "full" })` (`AKAN_MCP_OUTPUT_SCHEMA`) restores the closure; `"none"` publishes no
|
|
161
|
+
`outputSchema` at all — results still ship as `structuredContent`, and the text block stays on whatever
|
|
162
|
+
`legacyTextBlock` said, because a client validates structured content only against a declared schema. Inside a
|
|
163
|
+
`$defs` an id carries no `pattern` and nullability rides in `type: [T, "null"]` (an enum lists `null` too); a
|
|
164
|
+
top-level argument keeps the spellings the OpenAPI document uses. **Know which bytes reach the model.** The
|
|
165
|
+
provider tool definition a client builds from a listing carries the name, the description and the input schema —
|
|
166
|
+
the Anthropic and OpenAI tool formats have no field for an output schema — so `outputSchema` is a wire and
|
|
167
|
+
client-memory cost, and the model's context is spent on names, descriptions, input schemas and the number of
|
|
168
|
+
tools. Curate those (`mcp: false`, `mcp: { cru: false }`, `readOnly`) to cut context; pick `outputSchema` for the
|
|
169
|
+
transfer.
|
|
170
|
+
Measured on a 219-tool user listing: the wire carried 366KB, the client reported ≈41.6k tokens for it, and the
|
|
171
|
+
server's own count of names + descriptions + input schemas was 95KB — the same 41k at 2.4 characters per token.
|
|
172
|
+
So `outputSchema` (`full` / `shallow` / `none`) moves wire bytes and client memory and **not one model token**;
|
|
173
|
+
do not tune it expecting a context win. What does cost context: the tool count, and the input model inlined
|
|
174
|
+
behind every `create*` / `update*` (22 such tools were 8.7k of those 41k; the heaviest single tool was
|
|
175
|
+
`updateUser` at ~900 tokens) — `mcp: { cru: false }` on a model whose writes an agent has no business with is
|
|
176
|
+
the lever that pays. A custom mutation whose arguments are plain ids is cheap (nine contract tools, 1.2k). In
|
|
177
|
+
one line: `Person` for an act reserved for a person, `mcp: { cru: false }` for the tools whose input models cost
|
|
178
|
+
the model tokens, and `outputSchema` for nothing that reaches the model at all.
|
|
179
|
+
- A refused endpoint answers the *same* "unknown tool" as one that does not exist. Never make that
|
|
180
|
+
message more helpful — the difference is what enumerates your private surface. Every 401 and 403 is generalized
|
|
181
|
+
the same way, the framework's `Access denied by guard: Admin` and an app's `No authentication with roles: admin`
|
|
182
|
+
alike: the caller reads `You are not permitted to perform this action.`, because either sentence names your
|
|
183
|
+
authorization structure to the one caller barred from it. A domain `Err` at any other status resolves through
|
|
184
|
+
the dictionary and keeps its own words, placeholders filled.
|
|
185
|
+
- The `readOnly` / `destructive` / `idempotent` hints a client renders are derived from the endpoint type and key
|
|
186
|
+
and are not configurable. Clients are told to distrust hints; they are never a gate.
|
|
187
|
+
- **`AKAN_MCP_READONLY=true` is the read-only-deployment valve, not the exposure switch.** It drops every mutation
|
|
188
|
+
whatever it declared, and reports each one in the boot log like any other refusal.
|
|
189
|
+
- OAuth resource metadata is published at `/.well-known/oauth-protected-resource` (and at that path plus the mount
|
|
190
|
+
path, the spelling most clients try first). `auth.authorizationServers` / `AKAN_MCP_AUTH_SERVERS`,
|
|
191
|
+
`auth.scopes` / `AKAN_MCP_SCOPES`, `auth.resource` / `AKAN_MCP_RESOURCE` and `auth.verify` configure it;
|
|
192
|
+
`insufficient_scope` is enforced only once scopes are declared. Naming an issuer changes three things at once: a
|
|
193
|
+
request with no credential is answered 401 from `initialize` on (an MCP client starts its OAuth flow on nothing
|
|
194
|
+
else), a token carrying no `aud` is refused (that issuer mints for its other resources too), and the challenge
|
|
195
|
+
carries no `error` code (RFC 6750 §3.1 reserves it for a credential that was presented). A server naming no
|
|
196
|
+
issuer keeps anonymous access and accepts a first-party token, which is bound by app and environment instead.
|
|
197
|
+
- **The boot log names every published entry with no dictionary `.desc()`.** An agent picks a tool by its
|
|
198
|
+
description, so a missing one is a broken tool. What the framework generates has no text of its own and borrows
|
|
199
|
+
the model's: the generated list reads the `.of()` label, and the base CRUD tools append the model's `.desc()` to
|
|
200
|
+
their generated `Get X`. Write that model `.desc()` — it is the only text those entries can carry. There is no
|
|
201
|
+
`akan quality scan` rule for this any more: a source scanner found the exposure only as an `mcp:` literal, and
|
|
202
|
+
with exposure derived from the guards the resolved catalogue is the only place that can answer.
|
|
203
|
+
- A browser-hosted client needs `allowedOrigins` **and** the CORS answer the server sends back for those origins.
|
|
204
|
+
Every other MCP client sends no `Origin` at all, and the one that does is matched against the server's public
|
|
205
|
+
origin: the configured `auth.resource`'s when there is one (`libs/shared` derives it from the issuer, so a mounted
|
|
206
|
+
app is always pinned), and only otherwise the forwarded host — which is as trustworthy as an edge that
|
|
207
|
+
*overwrites* that header. The same origin names the `resource_metadata` URL in every 401 challenge.
|
|
208
|
+
`AKAN_MCP_RESOURCE` is the pin for an app that mounts no authorization server.
|
|
209
|
+
- **Every call that executes an endpoint is rate-limited per caller** — `tools/call`, `resources/read` and
|
|
210
|
+
`prompts/get`, never a listing. A caller is the bearer's session (`sid`), so one connector counts as one however
|
|
211
|
+
many connections it opens; a credential-less caller is the address the proxy recorded, and callers with none share
|
|
212
|
+
one bucket. Defaults are 120 calls a minute and 8 in flight, counted per process (N replicas grant N budgets); past
|
|
213
|
+
either the answer is HTTP 429 with JSON-RPC `-32010` and a `Retry-After`, and an unknown tool name is counted like
|
|
214
|
+
any other call. `option.setMcp({ rateLimit: { calls, windowMs, concurrent } })`, `rateLimit: false` to turn it off;
|
|
215
|
+
`AKAN_MCP_RATE_LIMIT=<calls>` or `<calls>/<seconds>` (`off` disables) and `AKAN_MCP_CONCURRENT`. The boot log says
|
|
216
|
+
which is in force, and warns when it is off.
|
|
217
|
+
- **A `resources/read` uri that does not decode** — a stray `%` — is `Unknown resource`, not a server failure.
|
|
218
|
+
- **A caller's own mistake is reported as one** and never as a server failure: an argument that is missing,
|
|
219
|
+
unparseable, outside its `enumOf`, or **undeclared** comes back as `isError` naming it — `additionalProperties: false` travels in the
|
|
220
|
+
published schema and nothing on the wire enforces it — and so does a document that is not there, as
|
|
221
|
+
`No <model> found for the arguments given.` A prompt, having no `isError` to carry a refusal, answers `-32602`.
|
|
222
|
+
Only a real failure logs a stack; an agent can drive the rest at will.
|
|
223
|
+
- **Three revisions are spoken**: the modern `2026-07-28` and the legacy `2025-11-25` / `2025-06-18`, which are
|
|
224
|
+
wire-identical over the POST-only surface this implements — a client whose proposal is not listed is told to
|
|
225
|
+
disconnect. An unknown proposal is answered at whichever end of that list it is closer to, and an unimplemented
|
|
226
|
+
method answers `404` to a modern client but `200` to a legacy one, whose era spends `404` on "your session is
|
|
227
|
+
gone".
|
|
228
|
+
- **A modern-era request mirrors `MCP-Protocol-Version` and `Mcp-Method` into headers** (plus `Mcp-Name` when the
|
|
229
|
+
body names one), and one that leaves a mirror out is refused just like one that contradicts the body: a gateway
|
|
230
|
+
rule keyed on a header never fires for the request that omitted it. Legacy requests are not checked. Capabilities
|
|
231
|
+
are derived from the catalogue; `prompts` is advertised when the build serves pages, since prompts are theirs.
|
|
232
|
+
- **An expired, wrongly-audienced or unverifiable bearer token is refused up front**, so an agent is told to
|
|
233
|
+
authenticate rather than that the tool does not exist. The signature is checked through `auth.verify`, which the
|
|
234
|
+
module that mints the tokens supplies (`libs/shared` does, from the app's signing secret); without a verifier the
|
|
235
|
+
claims are read unverified and a token signed wrong, like an opaque one, degrades to an anonymous caller.
|
|
236
|
+
- **`/mcp` reads the `Authorization` header and nothing else.** The `cookie` header is deleted from the request
|
|
237
|
+
before the account middleware runs, so a same-site page cannot drive `tools/call` on the visitor's ambient session
|
|
238
|
+
— the request class `CrossSiteGuard` shields every mutation from, which this route never passes through.
|
|
239
|
+
- **Resource URIs**: `akan://<model>/{id}`, `akan://<model>/list` for the model's own list, and
|
|
240
|
+
`akan://<model>/list/<sliceKey>` for a slice's. The root list takes no third segment on purpose — any token
|
|
241
|
+
there is one a slice could also be named. **Those three are the whole set**, so only the generated reads that
|
|
242
|
+
publish are addressable: a custom endpoint keeps its tool and gets no resource template, and the refused
|
|
243
|
+
`light<Model>` read has no address either.
|
|
244
|
+
- **The catalogue is one language**, `en` unless `language` says otherwise: it is built once at boot and cached by
|
|
245
|
+
clients, so there is no `Accept-Language` negotiation.
|
|
246
|
+
|
|
247
|
+
## Authentication — The Authorization Server Is The Same Process
|
|
248
|
+
|
|
249
|
+
`libs/shared/lib/_oauth` is an OAuth 2.1 authorization server (RFC 8414 metadata, PKCE `S256` only, RFC 7591
|
|
250
|
+
registration, Client ID Metadata Documents, RFC 9207 `iss`) served by the app that serves `/mcp`. Its `option.ts`
|
|
251
|
+
names the issuer and hands `McpAuth` a verifier, so mounting the lib is all an app does; the protocol engine it
|
|
252
|
+
delegates to lives in `akanjs/server` (`OAuthAuthorize`, `OAuthToken`, `OAuthRegistration`, `OAuthClientIdMetadata`,
|
|
253
|
+
`OAuthPkce`, `OAuthRedirect`, `OAuthMetadata`, `OAuthErrors`) and is unit-tested without a container.
|
|
254
|
+
|
|
255
|
+
- **Endpoints, at the origin's root:** `GET /.well-known/oauth-authorization-server`, `GET /oauth/authorize`,
|
|
256
|
+
`POST /oauth/token` (form-encoded), `POST /oauth/register`, `POST /oauth/revoke` (RFC 7009, form-encoded, the
|
|
257
|
+
same client authentication as the token endpoint). The consent step is `libs/shared/page/oauth/consent`
|
|
258
|
+
— a server-rendered page whose two forms post to `/api/approveOAuthConsent/<requestId>` and
|
|
259
|
+
`/api/denyOAuthConsent/<requestId>` and follow the 302 back to the client. The account's own controls are
|
|
260
|
+
`fetch.listOAuthConnections()` (one entry per live grant: `sessionId`, `clientId`, `clientName`, `userAgent`,
|
|
261
|
+
`createdAt`, `expiresAt`, `isCurrent`) and `fetch.revokeOAuthConnection(sessionId)`; the app renders the
|
|
262
|
+
connected-apps page, the lib supplies the two endpoints and the `oauth.connectedApps` / `disconnect` /
|
|
263
|
+
`noConnectedApps` / `currentConnection` translations. All of it is `mcp: false`.
|
|
264
|
+
- **Revocation closes a grant, not a token.** A refresh token or an access token both name one grant (the access
|
|
265
|
+
token carries the lineage as `sid`); revoking either closes the refresh lineage and denylists the lineage id for
|
|
266
|
+
an access token's lifetime, so a stateless token dies early — `/mcp` answers 401 `invalid_token` and
|
|
267
|
+
`AccountMiddleware` treats it as anonymous, at the next call. `/oauth/revoke` answers an empty 200 whether the token
|
|
268
|
+
was live, unknown, or another client's (RFC 7009 §2.2 — it must not double as a token oracle); only a failed client
|
|
269
|
+
authentication is 401. Changing a password still revokes every session, browser ones included; disconnecting an
|
|
270
|
+
app revokes that one.
|
|
271
|
+
- **A person may, a model may not.** The one honest marker of "a model is driving this call" is on the token: an
|
|
272
|
+
access token minted here names its `client_id` and the MCP resource as `aud`, and a browser session carries
|
|
273
|
+
neither; `SerAccount` declares those claims, so nothing casts to read them. Read it three ways, all in
|
|
274
|
+
`@libs/shared/srvkit`: `.with(AgentCall)` hands the endpoint a boolean (drop `sendEmails` on an agent's say-so);
|
|
275
|
+
`guards: [Every, Person]` refuses a model outright and, being `account`-scoped, hides the entry from every MCP
|
|
276
|
+
listing (signing, paying, anything the page treats as a deliberate act); `isAgentCall(context)` inside a guard or
|
|
277
|
+
service. All three read `context.origin === "mcp"` first — a field on `SignalContext`, never the trace, which can
|
|
278
|
+
be absent — and the token second, so an agent's token used over plain HTTP is still an agent. `Person` also
|
|
279
|
+
declares **`static agents = false`**, the guard-level marker the catalogue reads: an endpoint it guards is refused
|
|
280
|
+
from the document itself (named in the boot log like `mcp: false`, with the guards' `static name`s — the explorer's
|
|
281
|
+
filter labels, which need not be the class names), not merely
|
|
282
|
+
hidden per caller at listing time — so the boot count, the size line and every agent's listing agree, and no
|
|
283
|
+
second flag is needed. Any guard that admits no model may declare the marker; a listing still evaluates
|
|
284
|
+
`account`-scoped guards per caller, so the two mechanisms compose.
|
|
285
|
+
- **The flow:** `POST /mcp` with no token → 401 with `resource_metadata` → the client reads the PRM, then the AS
|
|
286
|
+
metadata, registers (or uses a pre-registered / URL `client_id`), opens `/oauth/authorize` in a browser → the
|
|
287
|
+
server parks the request and redirects to the consent page (via `signinPath?redirect=` when anonymous) → the user
|
|
288
|
+
allows → `redirect_uri?code&state&iss` → the client posts the code and its PKCE verifier to `/oauth/token` → an
|
|
289
|
+
access token (1h, `aud` = the MCP resource, `iss`, `client_id`, `sub`, and the account's `self`/`me`) plus a
|
|
290
|
+
rotating refresh token (30d; reuse revokes the family). The token is the app's own access JWT, so
|
|
291
|
+
`AccountMiddleware` and every guard read it exactly as a browser session; nothing is scoped.
|
|
292
|
+
- **Invariants:** an authorization request lives ten minutes, binds to the first signed-in account that opens it,
|
|
293
|
+
and is decided once; a code lives sixty seconds and is consumed on first exchange even if that exchange fails; a
|
|
294
|
+
redirect URI must be registered and match exactly, loopback ports excepted (RFC 8252 §7.3); a refresh token is
|
|
295
|
+
bound to the client it was issued to. A `client_id` that is an HTTPS URL with a path is fetched as a metadata
|
|
296
|
+
document: the name is judged first (no address literal, no reserved suffix, no port), then resolved, and refused
|
|
297
|
+
when any address is private, loopback, link-local, CGNAT or multicast — the rebinding window between lookup and
|
|
298
|
+
fetch is what the deployment's egress policy still owns.
|
|
299
|
+
- **Per-app configuration** goes in `env.server.*` under `oauth`: `consentPath` and `signinPath` (with the basePath
|
|
300
|
+
when the app has one — `/office/oauth/consent`), `clients` (pre-registered, with an optional `clientSecret`),
|
|
301
|
+
`dynamicRegistration` (on by default: Claude Code and claude.ai register themselves), `allowedRedirectSchemes`
|
|
302
|
+
(`["cursor"]` by default, for Cursor's desktop callback), `accessTokenSeconds` (3600, also how long a revoked
|
|
303
|
+
access token can outlive its grant), `clientIdMetadata: { enabled, refusePrivateAddresses }` (both on; `enabled:
|
|
304
|
+
false` turns Client ID Metadata Documents off and the metadata stops advertising them — Claude Code then registers
|
|
305
|
+
dynamically instead), `issuer` and `resource` (only when a tunnel or an edge makes the derived origin wrong),
|
|
306
|
+
`enabled: false` to take the server and the `/mcp` credential requirement away together. The signin page must
|
|
307
|
+
honour `?redirect=` to return the user to the consent page.
|
|
308
|
+
- **Clients:** Claude Code — `claude mcp add --transport http <name> <origin>/mcp` and nothing else; it registers,
|
|
309
|
+
opens the browser to `http://localhost:<port>/callback`, refreshes on 401. claude.ai — a custom connector by URL
|
|
310
|
+
(needs public HTTPS; DCR or the Advanced-settings client id/secret; callback
|
|
311
|
+
`https://claude.ai/api/mcp/auth_callback`). Cursor — the URL alone (DCR with a `cursor://` redirect), or a
|
|
312
|
+
pre-registered client in `mcp.json` `auth` with `http://localhost:8787/callback` /
|
|
313
|
+
`https://www.cursor.com/agents/mcp/oauth/callback` registered. A first-party browser token no longer opens `/mcp`
|
|
314
|
+
once the issuer is named; `--header "Authorization: Bearer …"` needs a token minted with `aud`.
|
|
315
|
+
- **`JWT_SECRET`** (or `security.jwtSecret`) is required outside `local`; the boot refuses without it, because the
|
|
316
|
+
derived fallback is seeded from the app, environment and repo names and forges an admin token for anyone who
|
|
317
|
+
knows them. `AKAN_ALLOW_DERIVED_JWT_SECRET=1` accepts that risk explicitly.
|
|
318
|
+
|
|
319
|
+
## Page Prompts — `page().prompt()`
|
|
320
|
+
**A prompt is a screen.** There is no `prompt()` on `endpoint()`: the page file declares it —
|
|
321
|
+
`page().param("projectId", ID, { desc: "The project to brief." }).prompt("briefProjectTickets", "Brief the ticket
|
|
322
|
+
board of one project.").render(…)` — and that is the whole surface. `prompts/list` is every page carrying
|
|
323
|
+
`.prompt()`; its `.param()` stages are the required arguments and its `.search()` stages the optional ones, a list
|
|
324
|
+
argument is comma-separated on the wire and its description says so, and an `ID`/`Int`/`enumOf` value is checked by
|
|
325
|
+
the page's own declaration. **`prompts/get` runs the page's body** — root layouts, layouts, then the page's
|
|
326
|
+
`.render()` — in the RSC worker under the caller's bearer token, the way a tab would open it. Nothing is rendered:
|
|
327
|
+
the JSX comes back as an element tree nobody walks, no client component runs, and the screen's data footprint is
|
|
328
|
+
exactly the `fetch.*` queries its body made, with the arguments it made them with. The answer is the description as
|
|
329
|
+
the first message (priority 1), one embedded `resource` per query masked by that endpoint's return model and
|
|
330
|
+
addressed by the `akan://` uri the tool answers to (`akan://<toolKey>?args` for a custom read) — one document read
|
|
331
|
+
in two shapes, a layout's `project` beside the page's `lightProject`, travels once as the larger — and a closing
|
|
332
|
+
`Tools for this screen: …` line naming the published tools of the modules the screen fetched from, minus the reads
|
|
333
|
+
whose answers are attached above and filtered to what this caller may see — which is how a model reading a ticket
|
|
334
|
+
board is handed the ticket tools and not three hundred. Lists are cut to `promptBudget` (60,000 characters;
|
|
335
|
+
`option.setMcp({ promptBudget })`, `AKAN_MCP_PROMPT_BUDGET`), largest first, and the cut is said: `Attached the
|
|
336
|
+
first 20 of 341 rows of \`ticketListInProject\`; call it for the rest.` Only lists are cut — a single document rides
|
|
337
|
+
whole, so the budget is what the lists are trimmed to fit under, not a ceiling a screen of large documents cannot
|
|
338
|
+
pass. A layout's own queries (a project selector's `projectListInOrg`) are part of every screen under it and are
|
|
339
|
+
attached too. **No argument fallback**: a required argument left out answers one message — `No projectId was named for
|
|
340
|
+
"briefProjectTickets". Find it with \`projectListBySearch\`, then run this prompt again with projectId=<id>.` —
|
|
341
|
+
and runs nothing, because a prompt cannot re-run itself and context filled in on a guess would never be used. A
|
|
342
|
+
page that redirects (`getSelf({ unauthorize })`), or whose `fetch.*` a guard refuses with 401/403, answers the 401
|
|
343
|
+
credential challenge when the call carried no token and `This screen is not available to the signed-in account.`
|
|
344
|
+
when it did — the same sentence for both, so a refused id is never confirmed to exist; `router.notFound()` and a
|
|
345
|
+
404 from a query are `No screen exists for these arguments.`; anything else the body throws is `The page failed to
|
|
346
|
+
load.`, with the cause in the server log only. **The description is the only instruction.** `<Agent.Guide>` is for the in-page agent, whose
|
|
347
|
+
levers are the screen's controls, and is never read here, where the levers are the API — so write the description
|
|
348
|
+
in English, in the API's vocabulary, and put a computed rule (the legal status transitions) into it as a static
|
|
349
|
+
table the model combines with the attached state. Names match `^[A-Za-z0-9_-]{1,64}$` and are unique across pages;
|
|
350
|
+
two pages claiming one name fail the listing. An API-only build (`web: false`) publishes no prompts, and the
|
|
351
|
+
in-page chat lists none of them: its `/` menu is its six built-in commands. Pick which screens to publish by this
|
|
352
|
+
rule: a page taking no arguments is the most useful prompt, since MCP has no completion — one taking an id is for
|
|
353
|
+
when the id is already in hand.
|
|
354
|
+
|
|
355
|
+
## Progress Reporting
|
|
356
|
+
**`McpProgress.report(n, { total, message })`** reports progress from anywhere inside a call, a service or adapter
|
|
357
|
+
frames down included, and is a no-op when nobody is streaming — so the same code runs unchanged over HTTP, a
|
|
358
|
+
websocket, and in tests. `McpProgress.streaming` says whether anyone is reading, for a report whose message
|
|
359
|
+
costs something to assemble.
|
|
360
|
+
|
|
361
|
+
## Rolling MCP Out To An App
|
|
362
|
+
|
|
363
|
+
The goal of exposing a service over MCP is that a person uses it through an agent instead of through the page:
|
|
364
|
+
one browser consent (the app's own sign-in included), then an hour-long access token and a thirty-day rotating
|
|
365
|
+
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
|
|
366
|
+
exactly what that person may do over HTTP — nothing is widened and nothing is scoped. Do these in order; steps 2
|
|
367
|
+
and 3 are the ones whose omission shows up as a 404 or a doubled basePath on the consent page.
|
|
368
|
+
|
|
369
|
+
1. **Mount the lib that carries the authorization server** (`libs/shared`) and confirm `lib/_oauth`,
|
|
370
|
+
`page/oauth/consent` and `srvkit/oauthServer.ts` are present. Its `option.ts` declares `auth` for `setMcp`, and
|
|
371
|
+
`setMcp` merges field by field in mount order with the app last — the app never restates `auth`.
|
|
372
|
+
2. **`akan.config.ts`: `syncPageLibs: true`** (or the lib's name in an array). `akan sync <app>` links the consent
|
|
373
|
+
route under every basePath the app declares; the link folder is generated and never committed.
|
|
374
|
+
3. **`env.server.*` → `oauth`.** `consentPath` / `signinPath` carry the basePath the browser sees
|
|
375
|
+
(`/office/oauth/consent`, `/office/signin`) and are omitted for an app without one. The server hands the
|
|
376
|
+
sign-in page `?redirect=` as the app's router names the route — basePath removed — because `router.push` puts
|
|
377
|
+
the locale and basePath back; the app adds nothing. `issuer` (`AKAN_MCP_AUTH_SERVERS` for the resource side)
|
|
378
|
+
only behind a tunnel or an edge that renames the host, `resource` (`AKAN_MCP_RESOURCE`) only when `path` moved
|
|
379
|
+
off `/mcp`, `clients` + `dynamicRegistration: false` only for a deployment that closes registration.
|
|
380
|
+
4. **The sign-in page honours `?redirect=`** by passing it unchanged to the password and SSO controls — a
|
|
381
|
+
router-space path, never prefixed by hand. Accepting it needs only `startsWith("/") && !startsWith("//")`.
|
|
382
|
+
5. **`lib/option.ts`: `option.setMcp({ instructions, legacyTextBlock: false })`** (`AKAN_MCP_INSTRUCTIONS`,
|
|
383
|
+
`AKAN_MCP_LEGACY_TEXT`). Leave `readOnly` (`AKAN_MCP_READONLY`) off when the point is to let people *use* the
|
|
384
|
+
service; it is the valve for a deployment that must not write, not the way to curate.
|
|
385
|
+
6. **`JWT_SECRET`** (or `security.jwtSecret`) in every deployment outside `local`; the boot refuses without it.
|
|
386
|
+
7. **Read the boot log** — `MCP catalogue: tools=… · listing …KB`, the `MCP catalogue cost:` line, one `warn` per
|
|
387
|
+
refusal, and the list of published entries with no `.desc()`. A tool missing from it is missing everywhere.
|
|
388
|
+
8. **Connect once per app** — `claude mcp add --transport http <name> <issuer>/mcp`; a second entry for the same
|
|
389
|
+
server with a pre-registered client tests a registration mode, it does not add anything a user needs.
|
|
390
|
+
|
|
391
|
+
## Choosing What To Open
|
|
392
|
+
|
|
393
|
+
One rule: **an endpoint is open to an agent when the same person could do the same thing from the page, under
|
|
394
|
+
the same guards.** What stays closed is closed because no agent has a reason to call it, not because of who may
|
|
395
|
+
— and that is the only thing `mcp: false` says.
|
|
396
|
+
|
|
397
|
+
| Endpoint | Decision |
|
|
398
|
+
|---|---|
|
|
399
|
+
| 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. |
|
|
400
|
+
| 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. |
|
|
401
|
+
| 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. |
|
|
402
|
+
| Steps of a UI state machine (`requestPhoneCodeForSignin`, `setPasswordInPrepareUser`, a prepare-user flow) | `mcp: false`. Perfectly guarded, and only ever called by mistake. |
|
|
403
|
+
| Authentication, SSO, and the OAuth protocol endpoints | Already `mcp: false` where they are declared. |
|
|
404
|
+
| File upload, an `Any` / `Upload` return, a required `Any` argument, `pubsub` / `message`, `light<Model>` | Refused by the framework; nothing to write. |
|
|
405
|
+
| Admin-only endpoints | Leave the `Admin` guard; an account-scope guard hides the entry from every caller it refuses. |
|
|
406
|
+
| An act only a person may take (signing, paying, sending to a customer) | `guards: [Every, Person]` refuses every agent and, because `Person` declares `static agents = false`, takes the entry out of the catalogue document — no `mcp: false` needed, the HTTP route untouched. When a person and a model may both call but not to the same effect, keep the guards and take `.with(AgentCall)` to narrow what the call sets in motion. |
|
|
407
|
+
|
|
408
|
+
- **Scope is what makes hiding possible.** `static scope: "account"` (a role check) is evaluated with no arguments
|
|
409
|
+
and filters the listing per caller; `"resource"` (`Can<Verb><Model>`, ownership) needs `context.getArg()`, stays
|
|
410
|
+
listed, and stops the call. Marking a resource guard `"account"` would make the listing lie — the marker has no
|
|
411
|
+
default for that reason.
|
|
412
|
+
- **Identity comes from the internal args** (`.with(Self)`, `.with(Account)`, `.with(Me)`), never from an id the
|
|
413
|
+
client sent, and the service re-checks ownership behind the guard.
|
|
414
|
+
- **An admin who signs in on the consent page mints an admin token.** A connector created under an admin account
|
|
415
|
+
passes `Admin` guards; treat that as an operational decision, and read the request's subject type when in doubt.
|
|
416
|
+
- **Return what answers the question.** A count-or-summary endpoint taking an id beats a document; a nullable model
|
|
417
|
+
return publishes no `outputSchema`, so prefer a non-null return plus an `Err`. Bulky page-only fields are
|
|
418
|
+
`field.visual`. Narrow a list with a named filter slice, never with the root list's raw `query`.
|
|
419
|
+
|
|
420
|
+
## Writing `instructions`
|
|
421
|
+
|
|
422
|
+
`*.abstract.md` and `<Agent.Guide>` never reach an MCP client. Exactly three texts do: `instructions`, every
|
|
423
|
+
published entry's `.desc()`, and a page's `.prompt()` description. All three are English, whatever the users speak
|
|
424
|
+
— the reader is a model, and the catalogue is built once in one `language` and cached by clients.
|
|
425
|
+
|
|
426
|
+
- **`instructions` is the workflow.** Five to eight sentences, in this order: what the app is and what its nouns
|
|
427
|
+
mean; **which tool to call first** and which ids the rest take; how a name becomes an id; what a write confirms
|
|
428
|
+
before it runs and which writes cannot be undone; what this server does *not* do and where that is done instead.
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
instructions: [
|
|
432
|
+
"Operations tools for a back-office that runs consulting projects.",
|
|
433
|
+
"An org is one company; a project belongs to an org; a ticket is one unit of work on a project.",
|
|
434
|
+
"Start from orgInSelf, then projectInOrg and ticketInProject; every other tool takes the ids those return.",
|
|
435
|
+
"Prefer ticketStatusInProject for 'how is it going' and ticketInProject only when the user asks for detail.",
|
|
436
|
+
"createTicket, startTicket and completeTicket write on the caller's behalf: confirm the project and title first.",
|
|
437
|
+
"Nothing here signs, invoices or uploads a file; those are deliberate acts on the record's own page.",
|
|
438
|
+
].join(" "),
|
|
439
|
+
```
|
|
440
|
+
- **`.desc()` is the reason to pick one tool** — what it returns and when to use it, two clauses. An id argument's
|
|
441
|
+
`.desc()` names the tool that returns it (`From projectInOrg`). The generated CRUD borrows the model's `.of()`
|
|
442
|
+
label and `.desc()`, which is the only text those entries can carry.
|
|
443
|
+
- **A page prompt is a workflow the user invokes** as a slash command, not one the model chooses — a weekly
|
|
444
|
+
report, an onboarding check. Its instruction is the `.prompt()` description alone and its attachments are what
|
|
445
|
+
the screen fetched, masked by their return models — so to change what a prompt hands the model, change what the
|
|
446
|
+
screen loads, and keep the description to the ask.
|
|
447
|
+
|
|
448
|
+
## Verifying An App
|
|
449
|
+
|
|
450
|
+
- `akan lint <app>`, `akan typecheck <app>`, `akan sync <app>`, then the boot log above.
|
|
451
|
+
- The lib's own suites run wherever it is mounted: the OAuth signal test (metadata, registration, both `authorize`
|
|
452
|
+
redirects, code exchange, `/mcp` with the minted token and the refusal of a first-party or forged one, refresh
|
|
453
|
+
rotation) and the refresh-session and option unit tests.
|
|
454
|
+
- **One exposure test per app**, in a `lib/<model>.signal.test.ts`: list `tools/list` with a user token and pin the
|
|
455
|
+
names with `toEqual`, and assert the curated and admin-only ones with `not.toContain`. Exposure is derived from
|
|
456
|
+
the guards, so a guard change fails this test first — which is the point. The framework's own pattern is
|
|
457
|
+
`pkgs/akanjs/server/mcp/mcp.integration.test.ts`.
|
|
458
|
+
- Live: connect a client, consent, call a tool, `claude mcp logout`, authenticate again. `AKAN_PUBLIC_LOG_LEVEL=debug`
|
|
459
|
+
prints one `OAuth token request grant_type=… client_id=…` line per token call; two refreshes in the same second
|
|
460
|
+
both answering 200 is the grace window working, not a fault.
|
|
@@ -21,6 +21,18 @@ Generate client state coordination for a module. Store connects generated fetch
|
|
|
21
21
|
- Do not perform persistence logic in React components when a store action can own it.
|
|
22
22
|
- Do not call private or internal signal operations from UI state.
|
|
23
23
|
|
|
24
|
+
## Form Draft Recovery
|
|
25
|
+
- `<model>FormDraft`, `restore<Model>FormDraft`, `discard<Model>FormDraft` and `load<Model>FormDraft` are
|
|
26
|
+
generated. Do not hand-write a draft action, and do not write `<model>Form` to browser storage yourself.
|
|
27
|
+
- The draft key carries the signed-in user, derived by hashing the auth token's payload with `iat`, `exp`, `nbf`
|
|
28
|
+
and `jti` removed. **Do not put a claim that changes on every issue into the JWT.** One would rotate the key on
|
|
29
|
+
every silent token refresh, and the symptom is a form that never recovers anything: the draft was written under
|
|
30
|
+
a key nothing looks for again, and it lingers until the seven-day sweep.
|
|
31
|
+
- `field.secret` and `field.hidden` values are never written to a draft, so they come back empty. That is the
|
|
32
|
+
intended trade — a user retypes a password — and is not something to work around.
|
|
33
|
+
- A model whose form holds a large `field.visual` body can pass the 256KB per-draft cap, and a draft over it is
|
|
34
|
+
dropped with a warning rather than truncated.
|
|
35
|
+
|
|
24
36
|
## Review Checklist
|
|
25
37
|
- The instruction points to current docs pages, not removed docs routes.
|
|
26
38
|
- Generated examples use current Akan builder APIs and scanner-friendly filenames.
|
|
@@ -17,6 +17,11 @@ Generate form and interaction fragments bound to store form state and generated
|
|
|
17
17
|
- Do not call service or signal directly from inputs.
|
|
18
18
|
- Do not duplicate validation that belongs in constants or service invariants.
|
|
19
19
|
- Do not build page layout here when Zone should compose it.
|
|
20
|
+
- Do not persist typed values yourself. The edit shells (`Load.Edit`, `Model.EditModal`, `Model.New`) save the
|
|
21
|
+
whole `<model>Form` and recover it on the next open; a Template that reaches for `localStorage` or a per-field
|
|
22
|
+
cache saves a fragment of a form and restores it over whatever the server just returned.
|
|
23
|
+
- The `Field.*` `cache` prop and the `Input` `cacheKey` prop are deprecated and store nothing. Turn recovery off
|
|
24
|
+
with `draft={false}` on the shell, or name a scope with `draft="<scope>"`; do not reintroduce a field-level one.
|
|
20
25
|
|
|
21
26
|
## Review Checklist
|
|
22
27
|
- The instruction points to current docs pages, not removed docs routes.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Query Rule",
|
|
3
|
+
"description": "Akan data access — filters, slices and hydration, full-text search, and cascade removal",
|
|
4
|
+
"group": "core",
|
|
5
|
+
"codegenPriority": 4,
|
|
6
|
+
"deprecated": false,
|
|
7
|
+
"scans": [
|
|
8
|
+
{
|
|
9
|
+
"type": "example",
|
|
10
|
+
"description": "Filter and model declarations that drive the generated query surface",
|
|
11
|
+
"path": "{apps,libs}/*/lib/*/*.document.ts",
|
|
12
|
+
"sample": 4
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"type": "example",
|
|
16
|
+
"description": "Slice declarations that expose a query to a client store",
|
|
17
|
+
"path": "{apps,libs}/*/lib/*/*.signal.ts",
|
|
18
|
+
"sample": 4
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"update": {
|
|
22
|
+
"filePath": "./queryRule.instruction.md",
|
|
23
|
+
"contents": ["slices and hydration", "generated filter methods", "text search", "cascade removal"],
|
|
24
|
+
"rules": [
|
|
25
|
+
"Use current Akan APIs only",
|
|
26
|
+
"State the rule and the reason it exists, never restate a type signature",
|
|
27
|
+
"Say which generated methods fire hooks and which do not",
|
|
28
|
+
"Keep it workspace-neutral — no app or repo names"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
}
|