akanjs 3.0.0-alpha.14 → 3.0.0-alpha.16
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/base/symbols.ts +1 -1
- package/common/index.ts +0 -1
- package/common/mcpExposure.ts +10 -17
- package/constant/purify.ts +17 -11
- package/constant/types.ts +14 -12
- package/dictionary/dictInfo.ts +3 -83
- package/dictionary/locale.ts +2 -16
- package/package.json +1 -1
- package/server/akanServer.ts +16 -5
- package/server/mcp/McpRouter.ts +11 -24
- package/signal/guard.ts +5 -3
- package/signal/mcp/McpDocument.ts +22 -58
- package/signal/serializer/fetch.serializer.ts +0 -3
- package/signal/slice.ts +11 -24
- package/signal/types.ts +0 -32
- package/store/actionTag.ts +5 -0
- package/store/agent/AgentBridge.ts +24 -24
- package/store/agent/StoreCatalogue.ts +15 -13
- package/store/rootStore.ts +2 -1
- package/store/store.ts +6 -2
- package/store/storeInstance.ts +5 -5
- package/store/storeRegistry.ts +1 -1
- package/types/base/symbols.d.ts +1 -1
- package/types/common/index.d.ts +1 -1
- package/types/common/mcpExposure.d.ts +3 -12
- package/types/constant/purify.d.ts +2 -2
- package/types/constant/types.d.ts +2 -2
- package/types/dictionary/dictInfo.d.ts +15 -46
- package/types/dictionary/locale.d.ts +1 -9
- package/types/server/akanServer.d.ts +4 -4
- package/types/server/mcp/McpRouter.d.ts +9 -17
- package/types/signal/guard.d.ts +4 -3
- package/types/signal/mcp/McpDocument.d.ts +7 -18
- package/types/signal/slice.d.ts +9 -11
- package/types/signal/types.d.ts +0 -30
- package/types/store/actionTag.d.ts +4 -0
- package/types/store/agent/AgentBridge.d.ts +1 -3
- package/types/store/rootStore.d.ts +2 -1
- package/types/store/store.d.ts +2 -1
- package/types/store/storeInstance.d.ts +3 -2
- package/types/ui/Signal/RestApi.d.ts +2 -3
- package/ui/Signal/RestApi.tsx +24 -37
package/base/symbols.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const STATE_META = Symbol.for("akan.state");
|
|
|
13
13
|
export const STATE_INIT_META = Symbol.for("akan.state.init");
|
|
14
14
|
export const STATE_DERIVED_META = Symbol.for("akan.state.derived");
|
|
15
15
|
export const ACTION_META = Symbol.for("akan.action");
|
|
16
|
-
/** Which module declared each action
|
|
16
|
+
/** Which module declared each action. See `ActionOwner`. */
|
|
17
17
|
export const ACTION_OWNER_META = Symbol.for("akan.action.owner");
|
|
18
18
|
/** What a dispatcher does, carried on the function so a component handed one can annotate the DOM with it. */
|
|
19
19
|
export const ACTION_TAG = Symbol.for("akan.action.tag");
|
package/common/index.ts
CHANGED
package/common/mcpExposure.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { capitalize } from "./capitalize";
|
|
|
8
8
|
* eventually disagree, and an audit surface that disagrees with the catalogue is worse than none.
|
|
9
9
|
*
|
|
10
10
|
* Every rejection returns the sentence an author reads, at boot in the server log and in the explorer. Fail-closed
|
|
11
|
-
* with no reason
|
|
11
|
+
* with no reason leaves an author whose endpoint is missing from the catalogue nowhere to look but the source.
|
|
12
12
|
*/
|
|
13
13
|
export interface McpExposureEndpoint {
|
|
14
14
|
type: string;
|
|
@@ -26,20 +26,10 @@ export interface McpExposureOption {
|
|
|
26
26
|
readOnly?: boolean;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** The author's overrides for the hints derived below. */
|
|
30
|
-
export interface McpExposureHints {
|
|
31
|
-
readOnly?: boolean;
|
|
32
|
-
destructive?: boolean;
|
|
33
|
-
idempotent?: boolean;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
29
|
/** `Any` publishes as the empty schema, which tells a model nothing — so it is left out rather than described. */
|
|
37
30
|
export const isMcpDescribableArg = (arg: { refName: string }) => arg.refName !== "Any";
|
|
38
31
|
|
|
39
|
-
/**
|
|
40
|
-
* Which slice-level verb opts in each CRUD endpoint a model generates. They carry no signal option of their own,
|
|
41
|
-
* so `mcp: { get: true }` on the slice is the only place their exposure can be written.
|
|
42
|
-
*/
|
|
32
|
+
/** Which CRUD verb a generated endpoint key is, or `null` when the key is not one of the five. */
|
|
43
33
|
export const mcpBaseVerbOf = (refName: string, key: string) => {
|
|
44
34
|
const cap = capitalize(refName);
|
|
45
35
|
if (key === refName || key === `light${cap}`) return "get" as const;
|
|
@@ -54,19 +44,22 @@ export const mcpBaseVerbOf = (refName: string, key: string) => {
|
|
|
54
44
|
* and never stand in for a guard. `openWorldHint` is always false: every endpoint reaches this app's own
|
|
55
45
|
* database, not the wider internet.
|
|
56
46
|
*/
|
|
57
|
-
export const mcpHintsOf = (key: string, endpoint: { type: string }
|
|
58
|
-
const readOnly =
|
|
59
|
-
const destructive =
|
|
47
|
+
export const mcpHintsOf = (key: string, endpoint: { type: string }) => {
|
|
48
|
+
const readOnly = endpoint.type === "query";
|
|
49
|
+
const destructive = !readOnly && /^(remove|delete)/.test(key);
|
|
60
50
|
return {
|
|
61
51
|
readOnlyHint: readOnly,
|
|
62
52
|
destructiveHint: destructive,
|
|
63
|
-
idempotentHint:
|
|
53
|
+
idempotentHint: readOnly || /^(set|update)/.test(key),
|
|
64
54
|
openWorldHint: false,
|
|
65
55
|
};
|
|
66
56
|
};
|
|
67
57
|
|
|
68
58
|
/** The sentence explaining why this endpoint is not in the catalogue, or `null` when it is. */
|
|
69
59
|
export const mcpRefusalOf = (endpoint: McpExposureEndpoint, { readOnly }: McpExposureOption = {}): string | null => {
|
|
60
|
+
|
|
61
|
+
if (!endpoint.guards?.length)
|
|
62
|
+
return "it declares no guards, and exposure follows them — write `guards: [Public]` if anonymous access is the intent.";
|
|
70
63
|
if (endpoint.type === "prompt") return mcpPromptRefusalOf(endpoint);
|
|
71
64
|
if (endpoint.type === "pubsub" || endpoint.type === "message")
|
|
72
65
|
return `\`${endpoint.type}\` rides the websocket, and its internal arguments read a socket an MCP request does not have.`;
|
|
@@ -76,7 +69,7 @@ export const mcpRefusalOf = (endpoint: McpExposureEndpoint, { readOnly }: McpExp
|
|
|
76
69
|
return `a return typed \`${endpoint.returns.refName}\` cannot be described to a model.`;
|
|
77
70
|
if (endpoint.fileUpload || endpoint.args.some((arg) => arg.refName === "Upload"))
|
|
78
71
|
return "a file upload has no MCP representation.";
|
|
79
|
-
if (endpoint.type === "mutation" && !endpoint.guards
|
|
72
|
+
if (endpoint.type === "mutation" && !endpoint.guards.some((name) => name !== "Public"))
|
|
80
73
|
return "a mutation needs a real guard — `[Public]` is having none, spelled out.";
|
|
81
74
|
const opaque = endpoint.args.find((arg) => !isMcpDescribableArg(arg) && arg.type !== "search" && !arg.nullable);
|
|
82
75
|
if (opaque)
|
package/constant/purify.ts
CHANGED
|
@@ -31,18 +31,24 @@ type Purified<O> = O extends BaseObject
|
|
|
31
31
|
: O extends object
|
|
32
32
|
? PurifiedModel<O>
|
|
33
33
|
: O;
|
|
34
|
-
type PurifiedWithObjectToId<T, StateKeys extends keyof GetStateObject<T> = keyof GetStateObject<T>> =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
[K in StateKeys as null extends T[K] ? K : never]?: Purified<T[K]> | undefined;
|
|
38
|
-
};
|
|
39
|
-
export type PurifiedModel<T> = T extends (infer S)[]
|
|
40
|
-
? PurifiedModel<S>[]
|
|
41
|
-
: T extends string | number | boolean | Dayjs | File
|
|
34
|
+
type PurifiedWithObjectToId<T, StateKeys extends keyof GetStateObject<T> = keyof GetStateObject<T>> =
|
|
35
|
+
|
|
36
|
+
unknown extends T
|
|
42
37
|
? T
|
|
43
|
-
:
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
: {
|
|
39
|
+
[K in StateKeys as null extends T[K] ? never : K]: Purified<T[K]>;
|
|
40
|
+
} & {
|
|
41
|
+
[K in StateKeys as null extends T[K] ? K : never]?: Purified<T[K]> | undefined;
|
|
42
|
+
};
|
|
43
|
+
export type PurifiedModel<T> = unknown extends T
|
|
44
|
+
? T
|
|
45
|
+
: T extends (infer S)[]
|
|
46
|
+
? PurifiedModel<S>[]
|
|
47
|
+
: T extends string | number | boolean | Dayjs | File
|
|
48
|
+
? T
|
|
49
|
+
: T extends Map<infer K, infer V>
|
|
50
|
+
? Map<K, PurifiedModel<V>>
|
|
51
|
+
: PurifiedWithObjectToId<T>;
|
|
46
52
|
|
|
47
53
|
export type UploadableClientArg<T> = [T] extends [File[]] ? File[] | FileList : T;
|
|
48
54
|
|
package/constant/types.ts
CHANGED
|
@@ -14,18 +14,20 @@ type ObjectToId<O> = O extends BaseObject
|
|
|
14
14
|
? DocumentModel<O>
|
|
15
15
|
: O;
|
|
16
16
|
|
|
17
|
-
type Docify<T, _StateKeys extends keyof GetStateObject<T> = keyof GetStateObject<T>> =
|
|
18
|
-
|
|
19
|
-
} & {
|
|
20
|
-
|
|
21
|
-
};
|
|
22
|
-
export type DocumentModel<T> =
|
|
23
|
-
?
|
|
24
|
-
: T extends
|
|
25
|
-
?
|
|
26
|
-
: T extends
|
|
27
|
-
?
|
|
28
|
-
:
|
|
17
|
+
type Docify<T, _StateKeys extends keyof GetStateObject<T> = keyof GetStateObject<T>> = unknown extends T
|
|
18
|
+
? T
|
|
19
|
+
: { [K in _StateKeys as null extends T[K] ? never : K]-?: ObjectToId<NonNullable<T[K]>> } & {
|
|
20
|
+
[K in _StateKeys as null extends T[K] ? K : never]?: ObjectToId<NonNullable<T[K]>> | undefined;
|
|
21
|
+
};
|
|
22
|
+
export type DocumentModel<T> = unknown extends T
|
|
23
|
+
? T
|
|
24
|
+
: T extends (infer S)[]
|
|
25
|
+
? DocumentModel<S>[]
|
|
26
|
+
: T extends string | number | boolean | Dayjs | File
|
|
27
|
+
? T
|
|
28
|
+
: T extends Map<infer K, infer V>
|
|
29
|
+
? Map<K, DocumentModel<V>>
|
|
30
|
+
: Docify<T>;
|
|
29
31
|
|
|
30
32
|
export type FieldState<T> = T extends { id: string } ? T | null : T;
|
|
31
33
|
export type DefaultOf<S> = GetStateObject<{ [K in keyof S]: FieldState<S[K]> }>;
|
package/dictionary/dictInfo.ts
CHANGED
|
@@ -56,17 +56,6 @@ type DictEndpointShape<Endpoint> =
|
|
|
56
56
|
: Endpoint extends Record<string, EndpointInfo>
|
|
57
57
|
? EndpointCompactShape<Endpoint>
|
|
58
58
|
: Record<never, never>;
|
|
59
|
-
/**
|
|
60
|
-
* The actions of a store, which is every method on it that dispatches.
|
|
61
|
-
*
|
|
62
|
-
* Derived from the shape rather than from a marker like `ENDPOINT_DICT_SHAPE`, because a store's custom actions
|
|
63
|
-
* are declared on the subclass — after `store()` has already returned, so there is nothing for it to stamp. What
|
|
64
|
-
* makes the shape readable anyway is that `st.do.<action>()` is typed `void`: an action returns nothing, so the
|
|
65
|
-
* void-returning methods are exactly the actions and `get` / `pick` / `slice` fall out on their own.
|
|
66
|
-
*/
|
|
67
|
-
type DictStoreShape<Store> = {
|
|
68
|
-
[K in keyof Store as Store[K] extends (...args: never[]) => void | Promise<void> ? K & string : never]: true;
|
|
69
|
-
};
|
|
70
59
|
type DictArgNames<ArgNames> = ArgNames extends readonly string[] ? ArgNames[number] : never;
|
|
71
60
|
type DictFilterQuery<Filter> = DictFilterShape<Filter>["query"];
|
|
72
61
|
type DictFilterSort<Filter> = DictFilterShape<Filter>["sort"];
|
|
@@ -151,7 +140,6 @@ export class ModelDictInfo<
|
|
|
151
140
|
BaseSignalKey extends string = never,
|
|
152
141
|
SliceKey extends string = "",
|
|
153
142
|
EndpointKey extends string = never,
|
|
154
|
-
StoreKey extends string = never,
|
|
155
143
|
ErrorKey extends string = never,
|
|
156
144
|
EtcKey extends string = never,
|
|
157
145
|
> {
|
|
@@ -257,9 +245,6 @@ export class ModelDictInfo<
|
|
|
257
245
|
endpointDictionary: { [K in EndpointKey]: FunctionTranslation<Languages> } = {} as {
|
|
258
246
|
[K in EndpointKey]: FunctionTranslation<Languages>;
|
|
259
247
|
};
|
|
260
|
-
storeDictionary: { [K in StoreKey]: FieldTranslation<Languages> } = {} as {
|
|
261
|
-
[K in StoreKey]: FieldTranslation<Languages>;
|
|
262
|
-
};
|
|
263
248
|
errorDictionary: { [K in ErrorKey]: Languages } = {} as {
|
|
264
249
|
[K in ErrorKey]: Languages;
|
|
265
250
|
};
|
|
@@ -293,7 +278,6 @@ export class ModelDictInfo<
|
|
|
293
278
|
BaseSignalKey,
|
|
294
279
|
SliceKey,
|
|
295
280
|
EndpointKey,
|
|
296
|
-
StoreKey,
|
|
297
281
|
ErrorKey,
|
|
298
282
|
EtcKey
|
|
299
283
|
>;
|
|
@@ -318,7 +302,6 @@ export class ModelDictInfo<
|
|
|
318
302
|
BaseSignalKey,
|
|
319
303
|
SliceKey,
|
|
320
304
|
EndpointKey,
|
|
321
|
-
StoreKey,
|
|
322
305
|
ErrorKey,
|
|
323
306
|
EtcKey
|
|
324
307
|
>;
|
|
@@ -344,7 +327,6 @@ export class ModelDictInfo<
|
|
|
344
327
|
BaseSignalKey,
|
|
345
328
|
SliceKey,
|
|
346
329
|
EndpointKey,
|
|
347
|
-
StoreKey,
|
|
348
330
|
ErrorKey,
|
|
349
331
|
EtcKey
|
|
350
332
|
>;
|
|
@@ -369,7 +351,6 @@ export class ModelDictInfo<
|
|
|
369
351
|
BaseSignalKey,
|
|
370
352
|
SliceKey,
|
|
371
353
|
EndpointKey,
|
|
372
|
-
StoreKey,
|
|
373
354
|
ErrorKey,
|
|
374
355
|
EtcKey
|
|
375
356
|
>;
|
|
@@ -394,7 +375,6 @@ export class ModelDictInfo<
|
|
|
394
375
|
BaseSignalKey,
|
|
395
376
|
SliceKey,
|
|
396
377
|
EndpointKey,
|
|
397
|
-
StoreKey,
|
|
398
378
|
ErrorKey,
|
|
399
379
|
EtcKey
|
|
400
380
|
>;
|
|
@@ -420,7 +400,6 @@ export class ModelDictInfo<
|
|
|
420
400
|
BaseSignalKey,
|
|
421
401
|
keyof DictSliceShape<Slice> & string,
|
|
422
402
|
EndpointKey,
|
|
423
|
-
StoreKey,
|
|
424
403
|
ErrorKey,
|
|
425
404
|
EtcKey
|
|
426
405
|
>;
|
|
@@ -446,41 +425,6 @@ export class ModelDictInfo<
|
|
|
446
425
|
BaseSignalKey,
|
|
447
426
|
SliceKey,
|
|
448
427
|
keyof DictEndpointShape<Endpoint> & string,
|
|
449
|
-
StoreKey,
|
|
450
|
-
ErrorKey,
|
|
451
|
-
EtcKey
|
|
452
|
-
>;
|
|
453
|
-
}
|
|
454
|
-
/**
|
|
455
|
-
* What a store's custom actions are called, in the words a person would use.
|
|
456
|
-
*
|
|
457
|
-
* Optional, and the only stage that is: an action whose name matches the endpoint it wraps already reads as
|
|
458
|
-
* that endpoint's `.desc()`, which is most of them — the house naming rule (`st.do.X` reads the same as
|
|
459
|
-
* `fetch.X`) is what makes that true. This stage is for the rest, where inheriting the endpoint's words would
|
|
460
|
-
* be actively wrong: nine `getSummaryListIn*` actions that all call one endpoint, or `logout` over
|
|
461
|
-
* `signoutUser`, where the store name is the verb a user would say and the endpoint name is the verb the API
|
|
462
|
-
* has. `akan quality scan` names those and no others.
|
|
463
|
-
*
|
|
464
|
-
* Labels only, no `.arg()`. Parameter names are not in a class's type the way an endpoint builder's are, and an
|
|
465
|
-
* action mostly takes none anyway — its data comes from the form state the user already filled in.
|
|
466
|
-
*/
|
|
467
|
-
store<Store>(
|
|
468
|
-
translate: (t: (trans: Languages) => FieldTranslation<Languages>) => Partial<{
|
|
469
|
-
[K in keyof DictStoreShape<Store>]: FieldTranslation<Languages>;
|
|
470
|
-
}>,
|
|
471
|
-
) {
|
|
472
|
-
Object.assign(this.storeDictionary, translate(FieldTranslation.translate));
|
|
473
|
-
return this as unknown as ModelDictInfo<
|
|
474
|
-
Languages,
|
|
475
|
-
ModelKey,
|
|
476
|
-
InsightKey,
|
|
477
|
-
QueryKey,
|
|
478
|
-
SortKey,
|
|
479
|
-
EnumKey,
|
|
480
|
-
BaseSignalKey,
|
|
481
|
-
SliceKey,
|
|
482
|
-
EndpointKey,
|
|
483
|
-
StoreKey | (keyof DictStoreShape<Store> & string),
|
|
484
428
|
ErrorKey,
|
|
485
429
|
EtcKey
|
|
486
430
|
>;
|
|
@@ -497,7 +441,6 @@ export class ModelDictInfo<
|
|
|
497
441
|
BaseSignalKey,
|
|
498
442
|
SliceKey,
|
|
499
443
|
EndpointKey,
|
|
500
|
-
StoreKey,
|
|
501
444
|
ErrorKey | (keyof ErrorDict & string),
|
|
502
445
|
EtcKey
|
|
503
446
|
>;
|
|
@@ -514,7 +457,6 @@ export class ModelDictInfo<
|
|
|
514
457
|
BaseSignalKey,
|
|
515
458
|
SliceKey,
|
|
516
459
|
EndpointKey,
|
|
517
|
-
StoreKey,
|
|
518
460
|
ErrorKey,
|
|
519
461
|
EtcKey | (keyof EtcDict & string)
|
|
520
462
|
>;
|
|
@@ -531,7 +473,6 @@ export class ModelDictInfo<
|
|
|
531
473
|
GetBaseSignalKey<RefName>,
|
|
532
474
|
SliceKey,
|
|
533
475
|
EndpointKey,
|
|
534
|
-
StoreKey,
|
|
535
476
|
ErrorKey,
|
|
536
477
|
EtcKey
|
|
537
478
|
>;
|
|
@@ -555,7 +496,6 @@ export class ModelDictInfo<
|
|
|
555
496
|
this.#registerBaseSignalToRoot(refName, rootDict);
|
|
556
497
|
this.#registerSliceToRoot(refName, rootDict);
|
|
557
498
|
this.#registerEndpointToRoot(refName, rootDict);
|
|
558
|
-
this.#registerStoreToRoot(refName, rootDict);
|
|
559
499
|
this.#registerErrorToRoot(refName, rootDict);
|
|
560
500
|
this.#registerModelToRoot(refName, rootDict);
|
|
561
501
|
this.#registerEtcToRoot(refName, rootDict);
|
|
@@ -838,22 +778,6 @@ export class ModelDictInfo<
|
|
|
838
778
|
});
|
|
839
779
|
}
|
|
840
780
|
/** Under its own `store` node rather than beside `signal`, because the two hold the same key by design. */
|
|
841
|
-
#registerStoreToRoot(refName: string, rootDict: RootDictionary) {
|
|
842
|
-
this.languages.forEach((language) => {
|
|
843
|
-
ensureNode(getRootModelNode(rootDict, language, refName), "store");
|
|
844
|
-
});
|
|
845
|
-
Object.entries(this.storeDictionary as { [key: string]: FieldTranslation<Languages> }).forEach(([key, value]) => {
|
|
846
|
-
value.trans.forEach((t, idx) => {
|
|
847
|
-
ensureNode(ensureNode(getTranslatedRootModelNode(rootDict, this.languages, idx, refName), "store"), key).t = t;
|
|
848
|
-
});
|
|
849
|
-
value.descTrans?.forEach((t, idx) => {
|
|
850
|
-
ensureNode(
|
|
851
|
-
ensureNode(ensureNode(getTranslatedRootModelNode(rootDict, this.languages, idx, refName), "store"), key),
|
|
852
|
-
"desc",
|
|
853
|
-
).t = t;
|
|
854
|
-
});
|
|
855
|
-
});
|
|
856
|
-
}
|
|
857
781
|
#registerErrorToRoot(refName: string, rootDict: RootDictionary) {
|
|
858
782
|
this.languages.forEach((language) => {
|
|
859
783
|
ensureNode(getRootModelNode(rootDict, language, refName), "error");
|
|
@@ -883,12 +807,11 @@ export class ModelDictInfo<
|
|
|
883
807
|
/**
|
|
884
808
|
* Every parameter of `ModelDictInfo` is listed here positionally, so a parameter added to the class has to be
|
|
885
809
|
* added to all three lists below in the same slot. Omitting one does not fail to compile — inference silently
|
|
886
|
-
* shifts, so the last parameter falls off the end and becomes its default `never
|
|
887
|
-
*
|
|
888
|
-
* the call site as `l("<model>.<key>")` no longer existing.
|
|
810
|
+
* shifts, so the last parameter falls off the end and becomes its default `never`, which reads at the call site
|
|
811
|
+
* as `l("<model>.<key>")` no longer existing.
|
|
889
812
|
*/
|
|
890
813
|
|
|
891
|
-
type AnyModelDictInfo = ModelDictInfo<any, any, any, any, any, any, any, any, any, any, any
|
|
814
|
+
type AnyModelDictInfo = ModelDictInfo<any, any, any, any, any, any, any, any, any, any, any>;
|
|
892
815
|
|
|
893
816
|
type MergeTwoModelDicts<ModelDict1, ModelDict2> =
|
|
894
817
|
ModelDict1 extends ModelDictInfo<
|
|
@@ -901,7 +824,6 @@ type MergeTwoModelDicts<ModelDict1, ModelDict2> =
|
|
|
901
824
|
infer BaseSignalKey1,
|
|
902
825
|
infer SliceKey1,
|
|
903
826
|
infer EndpointKey1,
|
|
904
|
-
infer StoreKey1,
|
|
905
827
|
infer ErrorKey1,
|
|
906
828
|
infer EtcKey1
|
|
907
829
|
>
|
|
@@ -915,7 +837,6 @@ type MergeTwoModelDicts<ModelDict1, ModelDict2> =
|
|
|
915
837
|
infer BaseSignalKey2,
|
|
916
838
|
infer SliceKey2,
|
|
917
839
|
infer EndpointKey2,
|
|
918
|
-
infer StoreKey2,
|
|
919
840
|
infer ErrorKey2,
|
|
920
841
|
infer EtcKey2
|
|
921
842
|
>
|
|
@@ -929,7 +850,6 @@ type MergeTwoModelDicts<ModelDict1, ModelDict2> =
|
|
|
929
850
|
BaseSignalKey1 | BaseSignalKey2,
|
|
930
851
|
SliceKey1 | SliceKey2,
|
|
931
852
|
EndpointKey1 | EndpointKey2,
|
|
932
|
-
StoreKey1 | StoreKey2,
|
|
933
853
|
ErrorKey1 | ErrorKey2,
|
|
934
854
|
EtcKey1 | EtcKey2
|
|
935
855
|
>
|
package/dictionary/locale.ts
CHANGED
|
@@ -168,14 +168,6 @@ export type ModelTrans<
|
|
|
168
168
|
}>;
|
|
169
169
|
error: { [K in ErrorKey]: Trans };
|
|
170
170
|
} & { [K in EtcKey]: Trans };
|
|
171
|
-
/**
|
|
172
|
-
* Store action labels, keyed off the resolved `.store()` keys rather than off the store class.
|
|
173
|
-
*
|
|
174
|
-
* The endpoint half needs the class because it reads argument names out of it. A store entry is a label and a
|
|
175
|
-
* description with no arguments to name, so the keys the stage already resolved are the whole of it — which is
|
|
176
|
-
* also why a generated `dict.ts` passes no store type at all.
|
|
177
|
-
*/
|
|
178
|
-
export type StoreTranslatorKey<T extends string, StoreKey extends string> = `${T}.store.${StoreKey}${"" | ".desc"}`;
|
|
179
171
|
export type ModelTranslatorKey<T extends string, Model, Insight, Filter, Slice, Endpoint, EtcKey extends string> =
|
|
180
172
|
| `${T}.modelName`
|
|
181
173
|
| `${T}.modelDesc`
|
|
@@ -242,14 +234,11 @@ export const registerModelTrans = <
|
|
|
242
234
|
infer _BaseSignalKey,
|
|
243
235
|
infer _SliceKey,
|
|
244
236
|
infer _EndpointKey,
|
|
245
|
-
infer StoreKey,
|
|
246
237
|
infer ErrorKey,
|
|
247
238
|
infer EtcKey
|
|
248
239
|
>
|
|
249
240
|
? DictModule<
|
|
250
|
-
|
|
251
|
-
| EnumTranslatorKey<EnumKey>
|
|
252
|
-
| StoreTranslatorKey<RefName, StoreKey>,
|
|
241
|
+
ModelTranslatorKey<RefName, Model, Insight, Filter, Slice, Endpoint, EtcKey> | EnumTranslatorKey<EnumKey>,
|
|
253
242
|
`${RefName}.error.${ErrorKey}`
|
|
254
243
|
>
|
|
255
244
|
: never => {
|
|
@@ -263,14 +252,11 @@ export const registerModelTrans = <
|
|
|
263
252
|
infer _BaseSignalKey,
|
|
264
253
|
infer _SliceKey,
|
|
265
254
|
infer _EndpointKey,
|
|
266
|
-
infer StoreKey,
|
|
267
255
|
infer ErrorKey,
|
|
268
256
|
infer EtcKey
|
|
269
257
|
>
|
|
270
258
|
? DictModule<
|
|
271
|
-
|
|
272
|
-
| EnumTranslatorKey<EnumKey>
|
|
273
|
-
| StoreTranslatorKey<RefName, StoreKey>,
|
|
259
|
+
ModelTranslatorKey<RefName, Model, Insight, Filter, Slice, Endpoint, EtcKey> | EnumTranslatorKey<EnumKey>,
|
|
274
260
|
`${RefName}.error.${ErrorKey}`
|
|
275
261
|
>
|
|
276
262
|
: never;
|
package/package.json
CHANGED
package/server/akanServer.ts
CHANGED
|
@@ -38,16 +38,16 @@ export interface AkanServerProps extends AkanLibProps {
|
|
|
38
38
|
|
|
39
39
|
export interface AkanServerOptions {
|
|
40
40
|
openapi?: boolean;
|
|
41
|
-
/**
|
|
41
|
+
/** `/mcp` is mounted by default; `false` takes it off, and the object form carries the rest of its settings. */
|
|
42
42
|
mcp?: boolean | McpServerOption;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export interface McpServerOption {
|
|
46
46
|
enabled?: boolean;
|
|
47
47
|
/**
|
|
48
|
-
* Drops every mutation from the catalogue whatever
|
|
49
|
-
* write, such as a read replica or a demo. Off by default
|
|
50
|
-
*
|
|
48
|
+
* Drops every mutation from the catalogue whatever its guards allow — for a deployment that must not be able to
|
|
49
|
+
* write, such as a read replica or a demo. Off by default, and reported per endpoint in the boot log, because a
|
|
50
|
+
* switch that silently unlists a published endpoint cannot tell its author why it vanished.
|
|
51
51
|
*/
|
|
52
52
|
readOnly?: boolean;
|
|
53
53
|
/**
|
|
@@ -116,7 +116,7 @@ export class AkanServer {
|
|
|
116
116
|
prefix = "/api";
|
|
117
117
|
websocketPrefix = "/ws";
|
|
118
118
|
openapi = AkanServer.#isOpenApiEnvEnabled();
|
|
119
|
-
mcp = AkanServer.#
|
|
119
|
+
mcp = AkanServer.#isEnvOn("AKAN_MCP", "AKAN_PUBLIC_MCP");
|
|
120
120
|
mcpReadOnly = AkanServer.#isEnvEnabled("AKAN_MCP_READONLY", "AKAN_PUBLIC_MCP_READONLY");
|
|
121
121
|
mcpAuth: McpAuthOption = AkanServer.#mcpAuthFromEnv();
|
|
122
122
|
mcpOption: Omit<McpServerOption, "enabled" | "readOnly" | "auth"> = AkanServer.#mcpOptionFromEnv();
|
|
@@ -593,6 +593,17 @@ export class AkanServer {
|
|
|
593
593
|
return names.some((name) => process.env[name] === "true" || process.env[name] === "1");
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
+
/**
|
|
597
|
+
* On unless the env says otherwise, which is the opposite of `#isEnvEnabled`.
|
|
598
|
+
*
|
|
599
|
+
* MCP exposure follows an endpoint's guards rather than an opt-in, so there is nothing an app has to declare for
|
|
600
|
+
* its catalogue to be right — and a switch that must be found before anything works is a switch most deployments
|
|
601
|
+
* never find. `AKAN_MCP=false` is the way off.
|
|
602
|
+
*/
|
|
603
|
+
static #isEnvOn(...names: string[]) {
|
|
604
|
+
return !names.some((name) => process.env[name] === "false" || process.env[name] === "0");
|
|
605
|
+
}
|
|
606
|
+
|
|
596
607
|
/** Both differ per environment, so they belong in env rather than in the app's source alongside the switch. */
|
|
597
608
|
static #mcpAuthFromEnv(): McpAuthOption {
|
|
598
609
|
const authorizationServers = AkanServer.#envList("AKAN_MCP_AUTH_SERVERS");
|
package/server/mcp/McpRouter.ts
CHANGED
|
@@ -118,47 +118,34 @@ export class McpRouter {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
|
-
* Says once, at boot, what this build actually published — and names every endpoint
|
|
122
|
-
* was kept out anyway.
|
|
121
|
+
* Says once, at boot, what this build actually published — and names every endpoint that was kept out.
|
|
123
122
|
*
|
|
124
|
-
* The rejections
|
|
125
|
-
* catalogue. That is the right default and the wrong silence
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
123
|
+
* The rejections are fail-closed by design: an endpoint MCP cannot carry, or whose guards do not admit it, is
|
|
124
|
+
* simply not in the catalogue. That is the right default and the wrong silence, and it matters more now that
|
|
125
|
+
* exposure follows the guards — nobody wrote an opt-in whose absence would explain a missing tool, so this log
|
|
126
|
+
* is the only place the answer exists. A refusal turns on a resolved return type and a resolved guard list, so
|
|
127
|
+
* it reads only from here.
|
|
129
128
|
*
|
|
130
|
-
* An entry published with no description rides here
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* that entry's description. This holds the resolved catalogue, so it can simply look.
|
|
134
|
-
*
|
|
135
|
-
* So does an entry that declares no guards. Its access is what an explicit `[Public]` would grant, so it is not
|
|
136
|
-
* a refusal — but nobody decided it, and a named slice inherits no `get:` from the slice call, which is the way
|
|
137
|
-
* that happens without anyone writing it down. `akan quality scan` names that shape too
|
|
138
|
-
* (`akan.mcp.unguarded-exposure`), the omission being syntactic; what is left for here is every exposure the
|
|
139
|
-
* scanner cannot read as a literal, and the generated entries it skips.
|
|
129
|
+
* An entry published with no description rides here for the same reason: the text every generated entry borrows
|
|
130
|
+
* is a *model* `.desc()`, which no source rule would read as that entry's description. This holds the resolved
|
|
131
|
+
* catalogue, so it can simply look.
|
|
140
132
|
*
|
|
141
133
|
* Called by whatever mounts the router rather than from `createRoutes`, so building a router to answer one
|
|
142
134
|
* request — which tests and tooling do — does not narrate a catalogue nobody asked about.
|
|
143
135
|
*/
|
|
144
136
|
report() {
|
|
145
137
|
try {
|
|
146
|
-
const { tools, prompts, resourceTemplates, refusals, undescribed
|
|
138
|
+
const { tools, prompts, resourceTemplates, refusals, undescribed } = this.#getDocument();
|
|
147
139
|
const counts = `tools=${tools.length} prompts=${prompts.length} resourceTemplates=${resourceTemplates.length}`;
|
|
148
140
|
McpRouter.logger.info(`MCP catalogue: ${counts}${this.#props.readOnly ? " (read-only deployment)" : ""}`);
|
|
149
141
|
|
|
150
142
|
if (!tools.length && !prompts.length)
|
|
151
143
|
McpRouter.logger.warn(
|
|
152
|
-
"MCP is enabled but nothing
|
|
144
|
+
"MCP is enabled but published nothing. Every candidate was refused; see the reasons below.",
|
|
153
145
|
);
|
|
154
146
|
for (const { key, reason } of refusals) McpRouter.logger.warn(`MCP did not expose "${key}": ${reason}`);
|
|
155
147
|
for (const { key, reason } of undescribed)
|
|
156
148
|
McpRouter.logger.warn(`MCP exposed "${key}" with no description: ${reason}`);
|
|
157
|
-
for (const key of unguarded)
|
|
158
|
-
McpRouter.logger.warn(
|
|
159
|
-
`MCP exposed "${key}", which declares no guards. Write \`guards: [Public]\` if anonymous reads are the intent — ` +
|
|
160
|
-
`a slice's \`guards: { get: … }\` reaches base CRUD and the root slice, never a named slice.`,
|
|
161
|
-
);
|
|
162
149
|
} catch (error) {
|
|
163
150
|
|
|
164
151
|
McpRouter.logger.warn(
|
package/signal/guard.ts
CHANGED
|
@@ -10,17 +10,19 @@ export interface Guard {
|
|
|
10
10
|
* with no arguments — which is what lets a catalogue hide what the caller certainly cannot use. `resource` needs
|
|
11
11
|
* the call's arguments and fails closed without them, so evaluating one early would erase legitimate entries.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Required, with no default. Exposure is decided by a guard rather than by an opt-in, so an unmarked guard would
|
|
14
|
+
* silently take the `resource` path and list its endpoint to every caller — the whole guarded surface's names,
|
|
15
|
+
* refused only at call time. There is no safe guess here, so the author states it.
|
|
15
16
|
*/
|
|
16
17
|
export type GuardScope = "account" | "resource";
|
|
17
18
|
|
|
18
|
-
export type GuardCls<Name extends string = string> = Cls<Guard, { readonly name: Name; readonly scope
|
|
19
|
+
export type GuardCls<Name extends string = string> = Cls<Guard, { readonly name: Name; readonly scope: GuardScope }>;
|
|
19
20
|
|
|
20
21
|
/** Creates a named guard base class for signal access checks. */
|
|
21
22
|
export const guard = <T extends string>(name: T): GuardCls<T> => {
|
|
22
23
|
return class Guard {
|
|
23
24
|
static name = name;
|
|
25
|
+
static scope: GuardScope = "account";
|
|
24
26
|
canPass(context: SignalContext): PromiseOrObject<boolean> {
|
|
25
27
|
return true;
|
|
26
28
|
}
|