@akanjs/cli 3.0.0-alpha.101 → 3.0.0-alpha.103
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
|
+
708d95c2692df8b7a0bdb905802d48d38352e0305215030af6c3d6aa468a9460
|
|
@@ -131,6 +131,11 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
131
131
|
`data-agent-zone` container; guides follow the layout cascade (ancestors and own, never a sibling's). Zone
|
|
132
132
|
membership is positional — there is no per-declaration zone key, so a lib component joins whatever zone the app
|
|
133
133
|
mounts it in.
|
|
134
|
+
- **Two agents' turns run in parallel; their tool calls take turns.** Tool execution is serialized across the
|
|
135
|
+
whole page, because `AgentAbort` and `AgentProgress` reach the running call through a module slot rather than a
|
|
136
|
+
parameter — two calls in flight restore each other's slot and the second one's progress goes nowhere. Only the
|
|
137
|
+
execution queues: a zone parked on an approval card holds nothing up, since waiting for the user sits outside
|
|
138
|
+
the queue, and a call that is aborted releases it.
|
|
134
139
|
- **Everything a zone publishes is named `<id>.<name>`,** and that name is what instructions must use. A bare
|
|
135
140
|
`createVideoProject` inside a zone naming its tool `videoProjectDraft.createVideoProject` is a tool that does
|
|
136
141
|
not exist: the model calls it, the surface answers `Unknown tool`, and a turn is gone. Build the name from the
|
|
@@ -438,16 +443,17 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
438
443
|
row, Tab completing its name, and Escape closing the menu and then, pressed again, the panel.
|
|
439
444
|
|
|
440
445
|
## Built-In Tools
|
|
441
|
-
- The framework publishes five built-ins on every store surface: `navigate` (internal paths only, the same
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
is worth waiting for — publish an `st.tool` beside the
|
|
446
|
+
- The framework publishes five built-ins on every store surface: `navigate` (internal paths only, the same router
|
|
447
|
+
`Link` rides), `goBack` (this session's history — global, because history is not a control a page owns and a page
|
|
448
|
+
that draws no back link is not one you may not leave — a screen that must not be left withholds them with `builtins`,
|
|
449
|
+
not with prose), `readScreen` (the rendered DOM as compact text — headings, links, control values, and `(disabled)`
|
|
450
|
+
on a control or button that has it; the chat's own UI is skipped via `data-agent-ui`, and a password value is never
|
|
451
|
+
read), `readState(key)` (one masked store key — a key that is unknown or that this screen does not read is refused
|
|
452
|
+
*with the keys it can* read, the way `readScreen` names its sections), and `highlight(target)`. Declaring a hook tool
|
|
453
|
+
under one of those names shadows the built-in, so reuse them only to mean that. **There is no general-purpose wait**:
|
|
454
|
+
a built-in one was reachable on every screen and a model spent it on whatever key it liked, parking turns nobody
|
|
455
|
+
asked to park. Waiting belongs to the screen that knows what is worth waiting for — publish an `st.tool` beside the
|
|
456
|
+
control that starts the work, and let it await the work.
|
|
451
457
|
- **A tool that changes the screen waits for the screen before it answers.** `router.push` returns while the RSC
|
|
452
458
|
payload is still in flight and a store action that fires `void fetch.*` commits a tick later, so `navigate`
|
|
453
459
|
awaits `ScreenSettle.wait()` — DOM quiescence, bounded, because the client router hands its promise to nobody —
|
|
@@ -470,7 +470,7 @@ Full contract — credential handshake, room revalidation, socket cleanup scopin
|
|
|
470
470
|
- **The guards are also the MCP exposure decision** — see MCP Exposure. An endpoint that names none is not published to agents at all, and a mutation whose only guard is `Public` is refused, so a missing `guards` array now costs visibility as well as authorization.
|
|
471
471
|
- Resource guards are `Can<Verb><Model>` classes in `srvkit/guards.ts` that `implements Guard` with an `async canPass(context)`. They **fail closed**: no resource named ⇒ `false`; a load that throws ⇒ `logger.warn` then `false`. Admin bypass goes first.
|
|
472
472
|
- Keep `static name = "User";` on guard classes. `fetch` serializes guard names and the API explorer filters on them; it looks like dead code, and deleting it breaks the UI. Comment it so the next reader knows.
|
|
473
|
-
- **Every guard class also declares `static scope: GuardScope`, and it is required with no default.** `"account"` means the verdict reads the caller and nothing about the call, so it can be evaluated with no arguments — which is what lets an MCP listing hide what this caller certainly cannot use. `"resource"` means it needs the call's arguments (`context.getArg()`) and fails closed without them, so it is never evaluated for a listing: the entry stays visible and is stopped at call time. Getting it wrong is not a type error, so the marker is mandatory rather than defaulted — `SignedIn` / `Admin` / role checks are `"account"`, and every `Can<Verb><Model>` is `"resource"`.
|
|
473
|
+
- **Every guard class also declares `static scope: GuardScope`, and it is required with no default.** `"account"` means the verdict reads the caller and nothing about the call, so it can be evaluated with no arguments — which is what lets an MCP listing hide what this caller certainly cannot use. `"resource"` means it needs the call's arguments (`context.getArg()`) and fails closed without them, so it is never evaluated for a listing: the entry stays visible and is stopped at call time. Getting it wrong is not a type error, so the marker is mandatory rather than defaulted — `SignedIn` / `Admin` / role checks are `"account"`, and every `Can<Verb><Model>` is `"resource"`. An `"account"` guard that throws at listing time reached for arguments it does not have: the entry is hidden from every caller and the guard is named once in the log as mismarked. A guard that admits no model at all — `Person` — adds `static agents = false`; the MCP catalogue then refuses every endpoint it guards outright, so the person-only act is absent from the document rather than hidden per caller.
|
|
474
474
|
- The acting user arrives via `.with(Self)` / `.with(CurrentUserId)` / `.with(Me)`. Never trust a client-supplied id.
|
|
475
475
|
- Guards ship with the library that owns the model and are imported by its own signals through the package path, so a mounting app inherits authorization and cannot forget it.
|
|
476
476
|
- Services re-check ownership even when a guard already gated the call — two independent gates.
|
|
@@ -617,22 +617,38 @@ it.
|
|
|
617
617
|
slice and generated CRUD only, never a named slice or a custom endpoint. It is an opt-**out**, and it is
|
|
618
618
|
curation, not authorization: HTTP serves the endpoint exactly as before.
|
|
619
619
|
- **Narrow by cost, and read the boot log first.** MCP forbids a `$ref` across entries, so every entry inlines the
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
620
|
+
schema of every model it mentions and the listing is re-sent whole to every agent that connects. By default a
|
|
621
|
+
response schema names a nested model instead of inlining it (`outputSchema: "shallow"`; `"full"` restores the
|
|
622
|
+
closure, `"none"` drops the schema and keeps the text block) and a request schema asks for a relation's id, which
|
|
623
|
+
is what the wire carries. `MCP catalogue: … · listing 214KB` plus a per-signal `MCP catalogue cost:` line says
|
|
624
|
+
where the rest went; `mcp: { cru: false }` on a model is the next lever. `outputSchema` moves wire bytes and client
|
|
625
|
+
memory only — the model receives names, descriptions and input schemas (measured: 219 tools ≈ 41k tokens either
|
|
626
|
+
way) — so tool count and the input model behind `create*` / `update*` are what cost context. In one line:
|
|
627
|
+
`Person` for an act reserved for a person, `mcp: { cru: false }` for the tools whose input models cost the model
|
|
628
|
+
tokens, `outputSchema` for nothing that reaches the model.
|
|
623
629
|
- Settings live in the app's `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions,
|
|
624
|
-
allowedOrigins, pageSize, language, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
|
|
630
|
+
allowedOrigins, pageSize, language, outputSchema, rateLimit, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
|
|
625
631
|
`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.
|
|
632
|
+
valve, not the exposure switch; `AKAN_MCP=false` takes the whole surface off. Every call that executes an
|
|
633
|
+
endpoint is rate-limited per caller (120 a minute, 8 in flight, per process; 429 + `Retry-After`) — `rateLimit`
|
|
634
|
+
/ `AKAN_MCP_RATE_LIMIT` tunes or disables it.
|
|
627
635
|
- **Authentication is the same server.** An app mounting `libs/shared` serves the OAuth 2.1 authorization server
|
|
628
636
|
beside `/mcp` — `/.well-known/oauth-authorization-server`, `/oauth/authorize`, `/oauth/token`, `/oauth/register`
|
|
629
637
|
(RFC 7591; Cursor's `cursor://` redirect admitted) plus the consent page `libs/shared/page/oauth/consent` — and
|
|
630
638
|
names itself as the issuer, so `/mcp` demands a bearer token, verifies its signature, and refuses one carrying no
|
|
631
639
|
`aud`. A token is the app's own access JWT plus `aud`/`iss`/`client_id`, so `AccountMiddleware` and the guards
|
|
632
640
|
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`, `
|
|
634
|
-
reads the `Authorization` header only — a cookie is dropped at the door — and `JWT_SECRET` is
|
|
635
|
-
`local`, because the derived fallback is forgeable from three public names.
|
|
641
|
+
`signinPath` with the basePath, `clients`, `dynamicRegistration`, `allowedRedirectSchemes`, `clientIdMetadata`,
|
|
642
|
+
`enabled`). `/mcp` reads the `Authorization` header only — a cookie is dropped at the door — and `JWT_SECRET` is
|
|
643
|
+
mandatory outside `local`, because the derived fallback is forgeable from three public names. A grant is revoked
|
|
644
|
+
whole — `POST /oauth/revoke` (RFC 7009) by the client, `fetch.revokeOAuthConnection(sessionId)` by the account's
|
|
645
|
+
owner, `fetch.listOAuthConnections()` to see them — and a revoked access token is refused at its next call.
|
|
646
|
+
- **A person may, a model may not.** An agent's token names its `client_id` and `aud`; a browser session has
|
|
647
|
+
neither, and `context.origin` is `"mcp"` for a call through the MCP endpoint. `guards: [Every, Person]` refuses a
|
|
648
|
+
model and, because `Person` declares `static agents = false`, takes the entry out of the MCP catalogue document
|
|
649
|
+
itself (named in the boot log like `mcp: false`); `.with(AgentCall)` hands an endpoint the same verdict as a
|
|
650
|
+
boolean to narrow what the call sets in motion; `isAgentCall(context)` is the function behind both
|
|
651
|
+
(`@libs/shared/srvkit`). Never sniff `aud` through a cast.
|
|
636
652
|
- **The refusals are fail-closed**: a declared `mcp: false`, an endpoint with no `guards`, a mutation with no real
|
|
637
653
|
guard, `pubsub` and `message`, an `Any` or `Upload` return, a file upload, a required `Any` argument, and the
|
|
638
654
|
generated `light<Model>` read. A `prompt` also refuses a list argument and any `Any` argument. **Every refusal
|
|
@@ -43,17 +43,21 @@ export class FileSlice extends slice(srv.file, {
|
|
|
43
43
|
the guard rules keep out.
|
|
44
44
|
- **Reach for it by cost, not by taste.** Every entry inlines the schema of every model it mentions, so a plain
|
|
45
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.
|
|
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.
|
|
47
50
|
|
|
48
51
|
## Configuration
|
|
49
52
|
Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
|
|
50
|
-
`version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, and `auth`. **Not `main.ts`**: the gateway
|
|
53
|
+
`version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`, and `auth`. **Not `main.ts`**: the gateway
|
|
51
54
|
there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
|
|
52
55
|
mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
|
|
53
56
|
declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
|
|
54
57
|
`AKAN_MCP_PATH`, `AKAN_MCP_VERSION`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`, `AKAN_MCP_PAGE_SIZE`,
|
|
55
|
-
`AKAN_MCP_LANGUAGE`, `AKAN_MCP_LEGACY_TEXT`, `
|
|
56
|
-
deployment that configures what the source does
|
|
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.
|
|
57
61
|
The two booleans answer to `AKAN_PUBLIC_MCP` / `AKAN_PUBLIC_MCP_READONLY` too, the same pairing `AKAN_OPENAPI`
|
|
58
62
|
has, and a value written in code wins over the env of the same name — an explicit `undefined` is not a value.
|
|
59
63
|
`AKAN_MCP_PATH` is normalized to a leading `/`, because the route key and the OAuth metadata path are both built by
|
|
@@ -143,6 +147,32 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
|
|
|
143
147
|
`AKAN_MCP_LEGACY_TEXT=false` — leaves a one-line pointer in the text block instead, for a deployment whose
|
|
144
148
|
clients read the structured half. A scalar return is unaffected: it has no structured half to point at, so it
|
|
145
149
|
ships as the value itself either way.
|
|
150
|
+
- **A request schema asks for a relation's id, and a response schema names a nested model.** `serialize` sends a
|
|
151
|
+
relation field as its id and the server never converts an object back, so publishing the related model's shape
|
|
152
|
+
asked an agent for what the document layer cannot take. On the response side every entry inlines the transitive
|
|
153
|
+
closure of every model it mentions (a `$ref` may not cross entries), and on an open catalogue 83% of the bytes
|
|
154
|
+
were the same schemas repeated — so a nested database model is published as `{ type: "object", description:
|
|
155
|
+
"<ModelName>" }` and the returned model keeps every field of its own; an embedded scalar stays inline. Measured,
|
|
156
|
+
that took a 347-tool listing from 1194KB to 532KB, and one user's paged `tools/list` to 320KB.
|
|
157
|
+
`option.setMcp({ outputSchema: "full" })` (`AKAN_MCP_OUTPUT_SCHEMA`) restores the closure; `"none"` publishes no
|
|
158
|
+
`outputSchema` at all — results still ship as `structuredContent`, and the text block stays on whatever
|
|
159
|
+
`legacyTextBlock` said, because a client validates structured content only against a declared schema. Inside a
|
|
160
|
+
`$defs` an id carries no `pattern` and nullability rides in `type: [T, "null"]` (an enum lists `null` too); a
|
|
161
|
+
top-level argument keeps the spellings the OpenAPI document uses. **Know which bytes reach the model.** The
|
|
162
|
+
provider tool definition a client builds from a listing carries the name, the description and the input schema —
|
|
163
|
+
the Anthropic and OpenAI tool formats have no field for an output schema — so `outputSchema` is a wire and
|
|
164
|
+
client-memory cost, and the model's context is spent on names, descriptions, input schemas and the number of
|
|
165
|
+
tools. Curate those (`mcp: false`, `mcp: { cru: false }`, `readOnly`) to cut context; pick `outputSchema` for the
|
|
166
|
+
transfer.
|
|
167
|
+
Measured on a 219-tool user listing: the wire carried 366KB, the client reported ≈41.6k tokens for it, and the
|
|
168
|
+
server's own count of names + descriptions + input schemas was 95KB — the same 41k at 2.4 characters per token.
|
|
169
|
+
So `outputSchema` (`full` / `shallow` / `none`) moves wire bytes and client memory and **not one model token**;
|
|
170
|
+
do not tune it expecting a context win. What does cost context: the tool count, and the input model inlined
|
|
171
|
+
behind every `create*` / `update*` (22 such tools were 8.7k of those 41k; the heaviest single tool was
|
|
172
|
+
`updateUser` at ~900 tokens) — `mcp: { cru: false }` on a model whose writes an agent has no business with is
|
|
173
|
+
the lever that pays. A custom mutation whose arguments are plain ids is cheap (nine contract tools, 1.2k). In
|
|
174
|
+
one line: `Person` for an act reserved for a person, `mcp: { cru: false }` for the tools whose input models cost
|
|
175
|
+
the model tokens, and `outputSchema` for nothing that reaches the model at all.
|
|
146
176
|
- A refused endpoint answers the *same* "unknown tool" as one that does not exist. Never make that
|
|
147
177
|
message more helpful — the difference is what enumerates your private surface. Every 401 and 403 is generalized
|
|
148
178
|
the same way, the framework's `Access denied by guard: Admin` and an app's `No authentication with roles: admin`
|
|
@@ -168,9 +198,19 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
|
|
|
168
198
|
`akan quality scan` rule for this any more: a source scanner found the exposure only as an `mcp:` literal, and
|
|
169
199
|
with exposure derived from the guards the resolved catalogue is the only place that can answer.
|
|
170
200
|
- A browser-hosted client needs `allowedOrigins` **and** the CORS answer the server sends back for those origins.
|
|
171
|
-
Every other MCP client sends no `Origin` at all, and the one that does is matched against the
|
|
172
|
-
|
|
173
|
-
|
|
201
|
+
Every other MCP client sends no `Origin` at all, and the one that does is matched against the server's public
|
|
202
|
+
origin: the configured `auth.resource`'s when there is one (`libs/shared` derives it from the issuer, so a mounted
|
|
203
|
+
app is always pinned), and only otherwise the forwarded host — which is as trustworthy as an edge that
|
|
204
|
+
*overwrites* that header. The same origin names the `resource_metadata` URL in every 401 challenge.
|
|
205
|
+
`AKAN_MCP_RESOURCE` is the pin for an app that mounts no authorization server.
|
|
206
|
+
- **Every call that executes an endpoint is rate-limited per caller** — `tools/call`, `resources/read` and
|
|
207
|
+
`prompts/get`, never a listing. A caller is the bearer's session (`sid`), so one connector counts as one however
|
|
208
|
+
many connections it opens; a credential-less caller is the address the proxy recorded, and callers with none share
|
|
209
|
+
one bucket. Defaults are 120 calls a minute and 8 in flight, counted per process (N replicas grant N budgets); past
|
|
210
|
+
either the answer is HTTP 429 with JSON-RPC `-32010` and a `Retry-After`, and an unknown tool name is counted like
|
|
211
|
+
any other call. `option.setMcp({ rateLimit: { calls, windowMs, concurrent } })`, `rateLimit: false` to turn it off;
|
|
212
|
+
`AKAN_MCP_RATE_LIMIT=<calls>` or `<calls>/<seconds>` (`off` disables) and `AKAN_MCP_CONCURRENT`. The boot log says
|
|
213
|
+
which is in force, and warns when it is off.
|
|
174
214
|
- **A `resources/read` uri that does not decode** — a stray `%` — is `Unknown resource`, not a server failure.
|
|
175
215
|
- **A caller's own mistake is reported as one** and never as a server failure: an argument that is missing,
|
|
176
216
|
unparseable or **undeclared** comes back as `isError` naming it — `additionalProperties: false` travels in the
|
|
@@ -210,9 +250,35 @@ delegates to lives in `akanjs/server` (`OAuthAuthorize`, `OAuthToken`, `OAuthReg
|
|
|
210
250
|
`OAuthPkce`, `OAuthRedirect`, `OAuthMetadata`, `OAuthErrors`) and is unit-tested without a container.
|
|
211
251
|
|
|
212
252
|
- **Endpoints, at the origin's root:** `GET /.well-known/oauth-authorization-server`, `GET /oauth/authorize`,
|
|
213
|
-
`POST /oauth/token` (form-encoded), `POST /oauth/register
|
|
253
|
+
`POST /oauth/token` (form-encoded), `POST /oauth/register`, `POST /oauth/revoke` (RFC 7009, form-encoded, the
|
|
254
|
+
same client authentication as the token endpoint). The consent step is `libs/shared/page/oauth/consent`
|
|
214
255
|
— 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.
|
|
256
|
+
`/api/denyOAuthConsent/<requestId>` and follow the 302 back to the client. The account's own controls are
|
|
257
|
+
`fetch.listOAuthConnections()` (one entry per live grant: `sessionId`, `clientId`, `clientName`, `userAgent`,
|
|
258
|
+
`createdAt`, `expiresAt`, `isCurrent`) and `fetch.revokeOAuthConnection(sessionId)`; the app renders the
|
|
259
|
+
connected-apps page, the lib supplies the two endpoints and the `oauth.connectedApps` / `disconnect` /
|
|
260
|
+
`noConnectedApps` / `currentConnection` translations. All of it is `mcp: false`.
|
|
261
|
+
- **Revocation closes a grant, not a token.** A refresh token or an access token both name one grant (the access
|
|
262
|
+
token carries the lineage as `sid`); revoking either closes the refresh lineage and denylists the lineage id for
|
|
263
|
+
an access token's lifetime, so a stateless token dies early — `/mcp` answers 401 `invalid_token` and
|
|
264
|
+
`AccountMiddleware` treats it as anonymous, at the next call. `/oauth/revoke` answers an empty 200 whether the token
|
|
265
|
+
was live, unknown, or another client's (RFC 7009 §2.2 — it must not double as a token oracle); only a failed client
|
|
266
|
+
authentication is 401. Changing a password still revokes every session, browser ones included; disconnecting an
|
|
267
|
+
app revokes that one.
|
|
268
|
+
- **A person may, a model may not.** The one honest marker of "a model is driving this call" is on the token: an
|
|
269
|
+
access token minted here names its `client_id` and the MCP resource as `aud`, and a browser session carries
|
|
270
|
+
neither; `SerAccount` declares those claims, so nothing casts to read them. Read it three ways, all in
|
|
271
|
+
`@libs/shared/srvkit`: `.with(AgentCall)` hands the endpoint a boolean (drop `sendEmails` on an agent's say-so);
|
|
272
|
+
`guards: [Every, Person]` refuses a model outright and, being `account`-scoped, hides the entry from every MCP
|
|
273
|
+
listing (signing, paying, anything the page treats as a deliberate act); `isAgentCall(context)` inside a guard or
|
|
274
|
+
service. All three read `context.origin === "mcp"` first — a field on `SignalContext`, never the trace, which can
|
|
275
|
+
be absent — and the token second, so an agent's token used over plain HTTP is still an agent. `Person` also
|
|
276
|
+
declares **`static agents = false`**, the guard-level marker the catalogue reads: an endpoint it guards is refused
|
|
277
|
+
from the document itself (named in the boot log like `mcp: false`, with the guards' `static name`s — the explorer's
|
|
278
|
+
filter labels, which need not be the class names), not merely
|
|
279
|
+
hidden per caller at listing time — so the boot count, the size line and every agent's listing agree, and no
|
|
280
|
+
second flag is needed. Any guard that admits no model may declare the marker; a listing still evaluates
|
|
281
|
+
`account`-scoped guards per caller, so the two mechanisms compose.
|
|
216
282
|
- **The flow:** `POST /mcp` with no token → 401 with `resource_metadata` → the client reads the PRM, then the AS
|
|
217
283
|
metadata, registers (or uses a pre-registered / URL `client_id`), opens `/oauth/authorize` in a browser → the
|
|
218
284
|
server parks the request and redirects to the consent page (via `signinPath?redirect=` when anonymous) → the user
|
|
@@ -224,13 +290,18 @@ delegates to lives in `akanjs/server` (`OAuthAuthorize`, `OAuthToken`, `OAuthReg
|
|
|
224
290
|
and is decided once; a code lives sixty seconds and is consumed on first exchange even if that exchange fails; a
|
|
225
291
|
redirect URI must be registered and match exactly, loopback ports excepted (RFC 8252 §7.3); a refresh token is
|
|
226
292
|
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,
|
|
293
|
+
document: the name is judged first (no address literal, no reserved suffix, no port), then resolved, and refused
|
|
294
|
+
when any address is private, loopback, link-local, CGNAT or multicast — the rebinding window between lookup and
|
|
295
|
+
fetch is what the deployment's egress policy still owns.
|
|
228
296
|
- **Per-app configuration** goes in `env.server.*` under `oauth`: `consentPath` and `signinPath` (with the basePath
|
|
229
297
|
when the app has one — `/office/oauth/consent`), `clients` (pre-registered, with an optional `clientSecret`),
|
|
230
298
|
`dynamicRegistration` (on by default: Claude Code and claude.ai register themselves), `allowedRedirectSchemes`
|
|
231
|
-
(`["cursor"]` by default, for Cursor's desktop callback), `accessTokenSeconds` (3600
|
|
232
|
-
|
|
233
|
-
|
|
299
|
+
(`["cursor"]` by default, for Cursor's desktop callback), `accessTokenSeconds` (3600, also how long a revoked
|
|
300
|
+
access token can outlive its grant), `clientIdMetadata: { enabled, refusePrivateAddresses }` (both on; `enabled:
|
|
301
|
+
false` turns Client ID Metadata Documents off and the metadata stops advertising them — Claude Code then registers
|
|
302
|
+
dynamically instead), `issuer` and `resource` (only when a tunnel or an edge makes the derived origin wrong),
|
|
303
|
+
`enabled: false` to take the server and the `/mcp` credential requirement away together. The signin page must
|
|
304
|
+
honour `?redirect=` to return the user to the consent page.
|
|
234
305
|
- **Clients:** Claude Code — `claude mcp add --transport http <name> <origin>/mcp` and nothing else; it registers,
|
|
235
306
|
opens the browser to `http://localhost:<port>/callback`, refreshes on 401. claude.ai — a custom connector by URL
|
|
236
307
|
(needs public HTTPS; DCR or the Advanced-settings client id/secret; callback
|
|
@@ -311,6 +382,7 @@ the same guards.** What stays closed is closed because no agent has a reason to
|
|
|
311
382
|
| Authentication, SSO, and the OAuth protocol endpoints | Already `mcp: false` where they are declared. |
|
|
312
383
|
| File upload, an `Any` / `Upload` return, a required `Any` argument, `pubsub` / `message`, `light<Model>` | Refused by the framework; nothing to write. |
|
|
313
384
|
| Admin-only endpoints | Leave the `Admin` guard; an account-scope guard hides the entry from every caller it refuses. |
|
|
385
|
+
| 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. |
|
|
314
386
|
|
|
315
387
|
- **Scope is what makes hiding possible.** `static scope: "account"` (a role check) is evaluated with no arguments
|
|
316
388
|
and filters the listing per caller; `"resource"` (`Can<Verb><Model>`, ownership) needs `context.getArg()`, stays
|
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.103",
|
|
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.103",
|
|
37
37
|
"chalk": "^5.6.2",
|
|
38
38
|
"commander": "^14.0.3",
|
|
39
39
|
"dayjs": "^1.11.20",
|