ankka 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +594 -0
- package/dist/_proto/ankka/protocol/v1/client_pb.js +172 -30
- package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +134 -0
- package/dist/_proto/ankka/protocol/v1/discovery_pb.js +51 -36
- package/dist/_proto/ankka/protocol/v1/mcp_pb.d.ts +120 -0
- package/dist/_proto/ankka/protocol/v1/mcp_pb.js +29 -0
- package/dist/agent.d.ts +10 -0
- package/dist/agent.js +20 -0
- package/dist/autonomous.d.ts +5 -0
- package/dist/autonomous.js +10 -0
- package/dist/client-keys.d.ts +89 -0
- package/dist/client-keys.js +182 -0
- package/dist/consumer.d.ts +5 -0
- package/dist/consumer.js +10 -0
- package/dist/endpoint.d.ts +10 -0
- package/dist/endpoint.js +20 -0
- package/dist/graph.d.ts +5 -0
- package/dist/graph.js +10 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -0
- package/dist/mcp-endpoint.d.ts +144 -0
- package/dist/mcp-endpoint.js +256 -0
- package/dist/routes.d.ts +25 -2
- package/dist/routes.js +29 -2
- package/dist/server/discovery.d.ts +7 -0
- package/dist/server/discovery.js +26 -2
- package/dist/server/http.d.ts +8 -0
- package/dist/server/http.js +10 -4
- package/dist/server/mcp.d.ts +17 -0
- package/dist/server/mcp.js +157 -0
- package/dist/server/server.d.ts +11 -1
- package/dist/server/server.js +19 -0
- package/dist/server/storageEndpoint.d.ts +14 -0
- package/dist/server/storageEndpoint.js +82 -0
- package/dist/service.d.ts +6 -1
- package/dist/service.js +47 -4
- package/dist/sigv4.d.ts +47 -0
- package/dist/sigv4.js +149 -0
- package/dist/spec.d.ts +8 -2
- package/dist/spec.js +53 -3
- package/dist/storage.d.ts +288 -0
- package/dist/storage.js +1081 -0
- package/dist/testkit/integration.d.ts +9 -0
- package/dist/testkit/integration.js +5 -1
- package/dist/testkit/kinds.d.ts +16 -4
- package/dist/testkit/kinds.js +36 -14
- package/dist/testkit/unit.d.ts +4 -1
- package/dist/testkit/unit.js +8 -4
- package/dist/timedAction.d.ts +10 -0
- package/dist/timedAction.js +20 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workflow.d.ts +8 -0
- package/dist/workflow.js +15 -0
- package/package.json +1 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// An MCP endpoint (protocol 1.20): a path, an access rule and a table of tools, which the platform
|
|
2
|
+
// serves to any MCP client over Streamable HTTP. The platform answers the protocol, verifies the
|
|
3
|
+
// credential (a client key or a token), checks the arguments against each tool's schema and asks this
|
|
4
|
+
// process to run the tool; the process never sees the credential, only the principal it stood for.
|
|
5
|
+
//
|
|
6
|
+
// export class CartMcp extends McpEndpoint {
|
|
7
|
+
// static readonly path = "/mcp"
|
|
8
|
+
// static readonly acl = Acl.anyOf(Acl.clientKeys, Acl.authenticated)
|
|
9
|
+
// static readonly tools = tools<CartMcp>()
|
|
10
|
+
// .tool("get_cart", "Reads one cart", s.record("GetCart", { cartId: s.string }), Cart,
|
|
11
|
+
// (args, ep) => ep.client.of(ShoppingCart, args.cartId).call(ShoppingCart.handlers.getCart).invoke())
|
|
12
|
+
// .command("checkout", "Checks a cart out", ShoppingCart, "checkout", { id: "cartId" })
|
|
13
|
+
// }
|
|
14
|
+
//
|
|
15
|
+
// A tool is either run by this process's own function, or a reference to a component's handler or to
|
|
16
|
+
// a route of one of this service's endpoints. References are resolved at registration, so one that
|
|
17
|
+
// names something not registered stops the service before it starts.
|
|
18
|
+
var _a;
|
|
19
|
+
import { codecFor, isCodec } from "./codec.js";
|
|
20
|
+
import { Endpoint } from "./endpoint.js";
|
|
21
|
+
import { isAcl, parseTemplate } from "./routes.js";
|
|
22
|
+
import { resolve, toJsonSchema } from "./schema.js";
|
|
23
|
+
/** The first protocol whose runtime serves a process's MCP endpoint. */
|
|
24
|
+
export const MCP_ENDPOINTS_SINCE = "1.20";
|
|
25
|
+
/** What a tool name may be: what MCP clients accept everywhere. */
|
|
26
|
+
export const TOOL_NAME = /^[a-z0-9_-]{1,128}$/;
|
|
27
|
+
/**
|
|
28
|
+
* The base of an MCP endpoint. One instance serves every call, so the call's principal and caller are
|
|
29
|
+
* on `this.request`, as for an HTTP endpoint; `client`, `secrets`, `services` and `clientKeys` likewise.
|
|
30
|
+
*/
|
|
31
|
+
export class McpEndpoint extends Endpoint {
|
|
32
|
+
}
|
|
33
|
+
/** The tools of an MCP endpoint, built as `tools<CartMcp>().tool(...).command(...)`. */
|
|
34
|
+
export class ToolTable {
|
|
35
|
+
declared;
|
|
36
|
+
constructor(declared = []) {
|
|
37
|
+
this.declared = Object.freeze([...declared]);
|
|
38
|
+
Object.freeze(this);
|
|
39
|
+
}
|
|
40
|
+
#with(tool) {
|
|
41
|
+
return new _a([...this.declared, Object.freeze(tool)]);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A tool this process runs: its arguments decoded with `input` (a record schema becomes the tool's
|
|
45
|
+
* object schema), its answer encoded with `reply` (a string answers as text; an object also as
|
|
46
|
+
* structured content). A thrown error or a refusal is the tool error the client is shown.
|
|
47
|
+
*/
|
|
48
|
+
tool(name, description, input, reply, run, options) {
|
|
49
|
+
return this.#with({ kind: "tool", name, description, input, reply, run: run, acl: options?.acl });
|
|
50
|
+
}
|
|
51
|
+
/** A tool that runs a command of a component, by its wire name: `command("checkout", "...", ShoppingCart, "checkout", { id: "cartId" })`. */
|
|
52
|
+
command(name, description, component, handler, options) {
|
|
53
|
+
return this.#reference("command", name, description, component, handler, options);
|
|
54
|
+
}
|
|
55
|
+
/** A tool that runs a query of a component; it cannot change anything. */
|
|
56
|
+
query(name, description, component, handler, options) {
|
|
57
|
+
return this.#reference("query", name, description, component, handler, options);
|
|
58
|
+
}
|
|
59
|
+
/** A tool that calls a route of one of this service's endpoints, in process: its path names are the arguments, and `body` the body. */
|
|
60
|
+
route(name, description, endpoint, method, path, options) {
|
|
61
|
+
return this.#with({ kind: "route", name, description, endpoint, method, path, acl: options?.acl });
|
|
62
|
+
}
|
|
63
|
+
#reference(kind, name, description, component, handler, options) {
|
|
64
|
+
const wire = typeof handler === "string" ? handler : handler?.name;
|
|
65
|
+
return this.#with({ kind, name, description, component, handler: wire, id: options?.id, input: options?.input, acl: options?.acl });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
_a = ToolTable;
|
|
69
|
+
/** An empty tool table to build on. */
|
|
70
|
+
export function tools() {
|
|
71
|
+
return new ToolTable();
|
|
72
|
+
}
|
|
73
|
+
function hasTokens(acl) {
|
|
74
|
+
if (acl === "authenticated")
|
|
75
|
+
return true;
|
|
76
|
+
return typeof acl === "object" && acl.kind === "anyOf" && acl.acls.some(hasTokens);
|
|
77
|
+
}
|
|
78
|
+
function objectSchema(properties, required) {
|
|
79
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
80
|
+
}
|
|
81
|
+
/** The JSON Schema of a tool's own input: an object schema, whatever the shape (a codec admits any object). */
|
|
82
|
+
function inputSchemaOf(input) {
|
|
83
|
+
if (isCodec(input))
|
|
84
|
+
return { type: "object" };
|
|
85
|
+
const schema = toJsonSchema(input);
|
|
86
|
+
if (schema.type !== "object")
|
|
87
|
+
return `its input must be a record, so its arguments are an object; it is ${String(resolve(input).kind)}`;
|
|
88
|
+
return schema;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolves an MCP endpoint class against the registered components and endpoints, collecting every
|
|
92
|
+
* problem. A reference to a component or route that is not registered is refused here, before start.
|
|
93
|
+
*/
|
|
94
|
+
export function registerMcpEndpoint(cls, components, endpoints, problems) {
|
|
95
|
+
const name = typeof cls === "function" && cls.name ? cls.name : String(cls);
|
|
96
|
+
let ok = true;
|
|
97
|
+
const fail = (m) => {
|
|
98
|
+
problems.push(`${name}: ${m}`);
|
|
99
|
+
ok = false;
|
|
100
|
+
};
|
|
101
|
+
const path = cls.path;
|
|
102
|
+
if (typeof path !== "string" || !path.startsWith("/") || (path.length > 1 && path.endsWith("/")) || path.includes("{")) {
|
|
103
|
+
fail(`needs a static path starting with "/", with no parameters and not ending with "/", not ${JSON.stringify(path)}`);
|
|
104
|
+
}
|
|
105
|
+
else if (path.startsWith("/_ankka/"))
|
|
106
|
+
fail(`path ${path} is the platform's`);
|
|
107
|
+
if (!isAcl(cls.acl))
|
|
108
|
+
fail("needs a static acl — Acl.clientKeys, Acl.authenticated, Acl.anyOf(...) or another rule. An unstated access rule is a decision nobody made");
|
|
109
|
+
if (cls.brokered !== undefined && typeof cls.brokered !== "boolean")
|
|
110
|
+
fail("brokered must be true or false");
|
|
111
|
+
if (cls.brokered === true && isAcl(cls.acl) && !hasTokens(cls.acl))
|
|
112
|
+
fail("is brokered, which signs people in with the service's issuer; its acl must admit Acl.authenticated");
|
|
113
|
+
for (const [what, value] of [["serverName", cls.serverName], ["serverVersion", cls.serverVersion]]) {
|
|
114
|
+
if (value !== undefined && (typeof value !== "string" || value.trim() === ""))
|
|
115
|
+
fail(`${what} must be a non-empty string`);
|
|
116
|
+
}
|
|
117
|
+
const table = cls.tools;
|
|
118
|
+
if (!(table instanceof ToolTable)) {
|
|
119
|
+
fail("needs a static tools table: tools().tool(...)");
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
if (table.declared.length === 0)
|
|
123
|
+
fail("declares no tool; an MCP endpoint needs at least one");
|
|
124
|
+
const resolved = [];
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
for (const t of table.declared) {
|
|
127
|
+
const where = `tool '${t.name}'`;
|
|
128
|
+
if (typeof t.name !== "string" || !TOOL_NAME.test(t.name)) {
|
|
129
|
+
fail(`tool '${String(t.name)}' is not a tool name ([a-z0-9_-]+, at most 128 characters)`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (seen.has(t.name))
|
|
133
|
+
fail(`declares the tool '${t.name}' twice`);
|
|
134
|
+
seen.add(t.name);
|
|
135
|
+
if (typeof t.description !== "string" || t.description.trim() === "")
|
|
136
|
+
fail(`${where} needs a description; it is what a model reads to choose it`);
|
|
137
|
+
if (t.acl !== undefined && !isAcl(t.acl))
|
|
138
|
+
fail(`${where}: unknown acl ${JSON.stringify(t.acl)}`);
|
|
139
|
+
const tool = resolveTool(t, where, components, endpoints, fail);
|
|
140
|
+
if (tool)
|
|
141
|
+
resolved.push(tool);
|
|
142
|
+
}
|
|
143
|
+
if (!ok)
|
|
144
|
+
return undefined;
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
id: name,
|
|
147
|
+
cls,
|
|
148
|
+
path,
|
|
149
|
+
acl: cls.acl,
|
|
150
|
+
tools: Object.freeze(resolved),
|
|
151
|
+
brokered: cls.brokered === true,
|
|
152
|
+
serverName: cls.serverName,
|
|
153
|
+
serverVersion: cls.serverVersion,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function resolveTool(t, where, components, endpoints, fail) {
|
|
157
|
+
const base = { name: t.name, description: t.description, acl: t.acl };
|
|
158
|
+
switch (t.kind) {
|
|
159
|
+
case "tool": {
|
|
160
|
+
if (typeof t.run !== "function") {
|
|
161
|
+
fail(`${where}: the handler is not a function`);
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const schema = typeof t.input === "object" && t.input !== null ? inputSchemaOf(t.input) : "it needs an input shape";
|
|
165
|
+
if (typeof schema === "string") {
|
|
166
|
+
fail(`${where}: ${schema}`);
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
return Object.freeze({ ...base, inputSchema: schema, run: Object.freeze({ kind: "function", input: t.input, reply: t.reply, run: t.run }) });
|
|
170
|
+
}
|
|
171
|
+
case "command":
|
|
172
|
+
case "query": {
|
|
173
|
+
const componentId = t.component?.componentId;
|
|
174
|
+
const registered = typeof componentId === "string" ? components.get(componentId) : undefined;
|
|
175
|
+
if (!registered || registered.cls !== t.component) {
|
|
176
|
+
fail(`${where} refers to the component ${typeof componentId === "string" ? componentId : String(t.component)}, which is not registered`);
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
if (registered.kind !== "event-sourced" && registered.kind !== "key-value" && registered.kind !== "workflow") {
|
|
180
|
+
fail(`${where} refers to ${registered.id}, a ${registered.kind}; a reference names an entity's or a workflow's handler`);
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
const handler = registered.handlers.get(t.handler);
|
|
184
|
+
if (!handler) {
|
|
185
|
+
fail(`${where} refers to the handler '${String(t.handler)}' of ${registered.id}, which it does not declare`);
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
if (t.kind === "query" && !handler.readOnly)
|
|
189
|
+
fail(`${where} is a query of '${handler.name}', which is a command`);
|
|
190
|
+
if (t.kind === "command" && handler.readOnly)
|
|
191
|
+
fail(`${where} is a command of '${handler.name}', which is a query; declare it with query(...)`);
|
|
192
|
+
if (handler.streaming)
|
|
193
|
+
fail(`${where} refers to '${handler.name}', which streams; a tool answers once`);
|
|
194
|
+
if (typeof t.id !== "string" || t.id.trim() === "") {
|
|
195
|
+
fail(`${where} needs the argument that names the ${registered.kind === "workflow" ? "workflow" : "entity"}: { id: "cartId" }`);
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
const properties = { [t.id]: { type: "string", description: `The id of the ${registered.id}` } };
|
|
199
|
+
const required = [t.id];
|
|
200
|
+
let input;
|
|
201
|
+
if (handler.input !== undefined) {
|
|
202
|
+
const shape = handler.input;
|
|
203
|
+
const json = isCodec(shape) ? { type: "object" } : toJsonSchema(shape);
|
|
204
|
+
if (t.input !== undefined || json.type !== "object" || isCodec(shape)) {
|
|
205
|
+
const argument = t.input ?? "input";
|
|
206
|
+
if (argument === t.id)
|
|
207
|
+
fail(`${where}: the id and the input are both the argument '${argument}'`);
|
|
208
|
+
properties[argument] = json;
|
|
209
|
+
required.push(argument);
|
|
210
|
+
input = { kind: "argument", name: argument };
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
const fields = (json.properties ?? {});
|
|
214
|
+
if (t.id in fields)
|
|
215
|
+
fail(`${where}: the input has a field '${t.id}', which is the id's argument; name the input with { input: "..." }`);
|
|
216
|
+
Object.assign(properties, fields);
|
|
217
|
+
required.push(...(json.required ?? []));
|
|
218
|
+
input = { kind: "merge" };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else if (t.input !== undefined)
|
|
222
|
+
fail(`${where} names an input argument, but '${handler.name}' takes no input`);
|
|
223
|
+
return Object.freeze({
|
|
224
|
+
...base,
|
|
225
|
+
inputSchema: objectSchema(properties, required),
|
|
226
|
+
run: Object.freeze({ kind: "handler", componentKind: registered.kind, componentId: registered.id, handler, id: t.id, input }),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
case "route": {
|
|
230
|
+
const endpoint = [...endpoints.values()].find((e) => e.cls === t.endpoint);
|
|
231
|
+
if (!endpoint) {
|
|
232
|
+
fail(`${where} refers to the endpoint ${typeof t.endpoint === "function" ? t.endpoint.name : String(t.endpoint)}, which is not registered`);
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
const relative = typeof t.path === "string" && t.path.startsWith(endpoint.prefix) ? t.path.slice(endpoint.prefix === "/" ? 0 : endpoint.prefix.length) || "/" : undefined;
|
|
236
|
+
const route = relative === undefined ? undefined : [...endpoint.routes.values()].find((r) => r.method === t.method && r.template === relative);
|
|
237
|
+
if (!route) {
|
|
238
|
+
fail(`${where} refers to ${t.method} ${t.path}, which ${endpoint.id} does not declare`);
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
if (route.streaming || route.socket) {
|
|
242
|
+
fail(`${where} refers to ${t.method} ${t.path}, which streams; a tool answers once`);
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
const paramNames = parseTemplate(route.template);
|
|
246
|
+
const properties = Object.fromEntries(paramNames.map((n) => [n, { type: "string" }]));
|
|
247
|
+
if (route.body !== undefined)
|
|
248
|
+
properties.body = { type: "object" };
|
|
249
|
+
return Object.freeze({ ...base, inputSchema: objectSchema(properties, [...paramNames]), run: Object.freeze({ kind: "route", endpointId: endpoint.id, route, paramNames }) });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** The codec a tool's own reply is encoded with, when it declares one. */
|
|
254
|
+
export function replyCodecOf(reply) {
|
|
255
|
+
return reply === undefined ? undefined : codecFor(reply);
|
|
256
|
+
}
|
package/dist/routes.d.ts
CHANGED
|
@@ -36,25 +36,48 @@ export interface CallersAcl {
|
|
|
36
36
|
readonly kind: "callers";
|
|
37
37
|
readonly callers: readonly CallerMatcher[];
|
|
38
38
|
}
|
|
39
|
+
/** Admitted when any of `acls` admits: an MCP endpoint's rule (protocol 1.20), `Acl.anyOf(Acl.clientKeys, Acl.authenticated)`. */
|
|
40
|
+
export interface AnyOfAcl {
|
|
41
|
+
readonly kind: "anyOf";
|
|
42
|
+
readonly acls: readonly Acl[];
|
|
43
|
+
}
|
|
39
44
|
/**
|
|
40
45
|
* Who may call: everyone, no one, authenticated callers only, or only the callers named. There is no default.
|
|
46
|
+
* An MCP endpoint may also admit a client key issued for it (`Acl.clientKeys`), and any of several rules
|
|
47
|
+
* (`Acl.anyOf`); an HTTP endpoint refuses both at registration.
|
|
41
48
|
*
|
|
42
49
|
* Outside a cluster every caller is `{ kind: "local" }`, and `allowCallers` admits it.
|
|
43
50
|
*/
|
|
44
|
-
export type Acl = "allow-all" | "deny-all" | "authenticated" | CallersAcl;
|
|
51
|
+
export type Acl = "allow-all" | "deny-all" | "authenticated" | "client-keys" | CallersAcl | AnyOfAcl;
|
|
45
52
|
export declare const Acl: Readonly<{
|
|
46
53
|
allowAll: Acl;
|
|
47
54
|
denyAll: Acl;
|
|
48
55
|
authenticated: Acl;
|
|
56
|
+
/** A client key issued for the MCP endpoint (protocol 1.20); its subject and attributes are the principal. */
|
|
57
|
+
clientKeys: Acl;
|
|
49
58
|
allowCallers(first: CallerMatcher, ...rest: CallerMatcher[]): Acl;
|
|
59
|
+
/** Admitted when any of the rules admits; for an MCP endpoint or one of its tools. */
|
|
60
|
+
anyOf(first: Acl, ...rest: Acl[]): Acl;
|
|
50
61
|
}>;
|
|
51
|
-
/** Whether `x` is an access rule: one of the
|
|
62
|
+
/** Whether `x` is an access rule: one of the words, a non-empty callers rule, or a non-empty `anyOf` of rules. */
|
|
52
63
|
export declare function isAcl(x: unknown): x is Acl;
|
|
64
|
+
/** Whether `acl` is one an HTTP endpoint or route can state: neither client keys nor `anyOf`, which are an MCP endpoint's. */
|
|
65
|
+
export declare function isEndpointAcl(acl: Acl): boolean;
|
|
53
66
|
/** Thrown from a route handler to answer a status other than 200 with a plain-text message. */
|
|
54
67
|
export declare class HttpProblem extends Error {
|
|
55
68
|
readonly status: number;
|
|
56
69
|
constructor(status: number, message: string);
|
|
57
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* @internal An answer whose status and content type the handler chooses, its body as given: the
|
|
73
|
+
* directory store's objects, served under the content type each was kept with.
|
|
74
|
+
*/
|
|
75
|
+
export declare class RawReply {
|
|
76
|
+
readonly status: number;
|
|
77
|
+
readonly contentType: string;
|
|
78
|
+
readonly body: Uint8Array;
|
|
79
|
+
constructor(status: number, contentType: string, body: Uint8Array);
|
|
80
|
+
}
|
|
58
81
|
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
|
59
82
|
type MaybePromise<T> = T | Promise<T>;
|
|
60
83
|
/** The parameter names in a template: `ParamNames<"/{cartId}/items/{productId}">` is `"cartId" | "productId"`. */
|
package/dist/routes.js
CHANGED
|
@@ -28,19 +28,31 @@ export const Acl = Object.freeze({
|
|
|
28
28
|
allowAll: "allow-all",
|
|
29
29
|
denyAll: "deny-all",
|
|
30
30
|
authenticated: "authenticated",
|
|
31
|
+
/** A client key issued for the MCP endpoint (protocol 1.20); its subject and attributes are the principal. */
|
|
32
|
+
clientKeys: "client-keys",
|
|
31
33
|
allowCallers(first, ...rest) {
|
|
32
34
|
return Object.freeze({ kind: "callers", callers: Object.freeze([first, ...rest]) });
|
|
33
35
|
},
|
|
36
|
+
/** Admitted when any of the rules admits; for an MCP endpoint or one of its tools. */
|
|
37
|
+
anyOf(first, ...rest) {
|
|
38
|
+
return Object.freeze({ kind: "anyOf", acls: Object.freeze([first, ...rest]) });
|
|
39
|
+
},
|
|
34
40
|
});
|
|
35
|
-
/** Whether `x` is an access rule: one of the
|
|
41
|
+
/** Whether `x` is an access rule: one of the words, a non-empty callers rule, or a non-empty `anyOf` of rules. */
|
|
36
42
|
export function isAcl(x) {
|
|
37
|
-
if (x === "allow-all" || x === "deny-all" || x === "authenticated")
|
|
43
|
+
if (x === "allow-all" || x === "deny-all" || x === "authenticated" || x === "client-keys")
|
|
38
44
|
return true;
|
|
39
45
|
if (typeof x !== "object" || x === null)
|
|
40
46
|
return false;
|
|
41
47
|
const rule = x;
|
|
48
|
+
if (rule.kind === "anyOf")
|
|
49
|
+
return Array.isArray(rule.acls) && rule.acls.length > 0 && rule.acls.every(isAcl);
|
|
42
50
|
return rule.kind === "callers" && Array.isArray(rule.callers) && rule.callers.length > 0;
|
|
43
51
|
}
|
|
52
|
+
/** Whether `acl` is one an HTTP endpoint or route can state: neither client keys nor `anyOf`, which are an MCP endpoint's. */
|
|
53
|
+
export function isEndpointAcl(acl) {
|
|
54
|
+
return acl !== "client-keys" && !(typeof acl === "object" && acl.kind === "anyOf");
|
|
55
|
+
}
|
|
44
56
|
/** Thrown from a route handler to answer a status other than 200 with a plain-text message. */
|
|
45
57
|
export class HttpProblem extends Error {
|
|
46
58
|
status;
|
|
@@ -50,6 +62,21 @@ export class HttpProblem extends Error {
|
|
|
50
62
|
this.status = status;
|
|
51
63
|
}
|
|
52
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* @internal An answer whose status and content type the handler chooses, its body as given: the
|
|
67
|
+
* directory store's objects, served under the content type each was kept with.
|
|
68
|
+
*/
|
|
69
|
+
export class RawReply {
|
|
70
|
+
status;
|
|
71
|
+
contentType;
|
|
72
|
+
body;
|
|
73
|
+
constructor(status, contentType, body) {
|
|
74
|
+
this.status = status;
|
|
75
|
+
this.contentType = contentType;
|
|
76
|
+
this.body = body;
|
|
77
|
+
Object.freeze(this);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
53
80
|
const TEMPLATE = /^(\/(?:[^/{}]+|\{[A-Za-z_][A-Za-z0-9_]*\})?)*$/;
|
|
54
81
|
export function parseTemplate(template) {
|
|
55
82
|
if (typeof template !== "string" || !template.startsWith("/") || !TEMPLATE.test(template) || template.includes("//")) {
|
|
@@ -6,6 +6,8 @@ import { type Spec } from "../_proto/ankka/protocol/v1/discovery_pb.ts";
|
|
|
6
6
|
* naming what declares them, rather than served wrong.
|
|
7
7
|
*/
|
|
8
8
|
export declare function refusal(spec: Spec, sidecarProtocol: string): string | undefined;
|
|
9
|
+
/** The protocol version whose runtimes admit a process's endpoint at `/_ankka/storage`. */
|
|
10
|
+
export declare const STORAGE_SINCE: readonly [number, number];
|
|
9
11
|
/** Every problem the sidecar has reported to this process, in order. */
|
|
10
12
|
export declare const problems: string[];
|
|
11
13
|
export declare function discoveryRoutes(router: ConnectRouter, spec: () => Spec, log?: (message: string) => void): void;
|
|
@@ -19,3 +21,8 @@ export declare const GRANTS_SINCE: readonly [number, number];
|
|
|
19
21
|
/** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.15
|
|
20
22
|
* does not know `Callers.granted`, and would admit nobody by it. */
|
|
21
23
|
export declare function grantsRefusal(spec: Spec, runtime: string): string | undefined;
|
|
24
|
+
/** The protocol version whose runtimes serve a process's MCP endpoint. */
|
|
25
|
+
export declare const MCP_SINCE: readonly [number, number];
|
|
26
|
+
/** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.20
|
|
27
|
+
* does not know an MCP endpoint, and would serve nothing at its path. */
|
|
28
|
+
export declare function mcpRefusal(spec: Spec, runtime: string): string | undefined;
|
package/dist/server/discovery.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Discovery } from "../_proto/ankka/protocol/v1/discovery_pb.js";
|
|
|
7
7
|
import { EmptySchema } from "../_proto/ankka/protocol/v1/payload_pb.js";
|
|
8
8
|
import { PROTOCOL_VERSION } from "../spec.js";
|
|
9
9
|
import { Kind } from "../_proto/ankka/protocol/v1/discovery_pb.js";
|
|
10
|
+
import { STORAGE_PATH } from "../storage.js";
|
|
10
11
|
import { olderThan, olderThanContracts, olderThanDeclaredQueries, olderThanStartPositions, olderThanViewStreams, olderThanWorkflowSources } from "../startFrom.js";
|
|
11
12
|
/**
|
|
12
13
|
* A sidecar older than 1.7 would ignore where a topic source starts and its version: a consumer
|
|
@@ -137,16 +138,26 @@ function startPositionRefusal(spec, sidecarProtocol) {
|
|
|
137
138
|
return (`${declaring.join(", ")} declare where a topic source starts or its version, which the sidecar ignores: ` +
|
|
138
139
|
`it speaks protocol ${sidecarProtocol}, and this SDK ${PROTOCOL_VERSION}. Run a sidecar speaking 1.7 or later.`);
|
|
139
140
|
}
|
|
141
|
+
/** The protocol version whose runtimes admit a process's endpoint at `/_ankka/storage`. */
|
|
142
|
+
export const STORAGE_SINCE = [1, 21];
|
|
140
143
|
/** Every problem the sidecar has reported to this process, in order. */
|
|
141
144
|
export const problems = [];
|
|
142
145
|
export function discoveryRoutes(router, spec, log = console.error) {
|
|
143
146
|
router.service(Discovery, {
|
|
144
147
|
async discover(info) {
|
|
145
148
|
log(`ankka: discovery from sidecar (protocol ${info.protocolVersion}, runtime ${info.runtimeVersion})`);
|
|
146
|
-
|
|
149
|
+
let answer = spec();
|
|
150
|
+
// A runtime before 1.21 refuses a process's endpoint under /_ankka; the directory store's URLs then
|
|
151
|
+
// go unanswered, and nothing else of the store needs the runtime.
|
|
152
|
+
if (olderThan(info.protocolVersion, STORAGE_SINCE) && answer.endpoints.some((e) => e.prefix === STORAGE_PATH)) {
|
|
153
|
+
log(`ankka: the sidecar speaks protocol ${info.protocolVersion}; the directory store's URLs at ${STORAGE_PATH} need 1.21 and are not served`);
|
|
154
|
+
answer = { ...answer, endpoints: answer.endpoints.filter((e) => e.prefix !== STORAGE_PATH) };
|
|
155
|
+
}
|
|
147
156
|
const why = refusal(answer, info.protocolVersion) ??
|
|
148
157
|
socketRefusal(answer, info.protocolVersion) ??
|
|
149
|
-
grantsRefusal(answer, info.protocolVersion)
|
|
158
|
+
grantsRefusal(answer, info.protocolVersion) ??
|
|
159
|
+
// Last: an older runtime is told the oldest thing it lacks first.
|
|
160
|
+
mcpRefusal(answer, info.protocolVersion);
|
|
150
161
|
if (why !== undefined) {
|
|
151
162
|
log(`ankka: ${why}`);
|
|
152
163
|
problems.push(why);
|
|
@@ -200,3 +211,16 @@ export function grantsRefusal(spec, runtime) {
|
|
|
200
211
|
return undefined;
|
|
201
212
|
return `this runtime speaks protocol ${runtime || "unknown"}; granted callers and another project's topics need 1.15 (${endpoints.join(", ")})`;
|
|
202
213
|
}
|
|
214
|
+
/** The protocol version whose runtimes serve a process's MCP endpoint. */
|
|
215
|
+
export const MCP_SINCE = [1, 20];
|
|
216
|
+
/** Why `spec` cannot be declared to a runtime speaking `runtime`, or undefined. A runtime before 1.20
|
|
217
|
+
* does not know an MCP endpoint, and would serve nothing at its path. */
|
|
218
|
+
export function mcpRefusal(spec, runtime) {
|
|
219
|
+
if (spec.mcpEndpoints.length === 0)
|
|
220
|
+
return undefined;
|
|
221
|
+
const [major, minor] = runtime.split(".").map((part) => Number.parseInt(part, 10));
|
|
222
|
+
const speaks = Number.isFinite(major) && Number.isFinite(minor) ? [major, minor] : [0, 0];
|
|
223
|
+
if (speaks[0] > MCP_SINCE[0] || (speaks[0] === MCP_SINCE[0] && speaks[1] >= MCP_SINCE[1]))
|
|
224
|
+
return undefined;
|
|
225
|
+
return `this runtime speaks protocol ${runtime || "unknown"}; an MCP endpoint needs 1.20 (${spec.mcpEndpoints.map((e) => e.path).join(", ")})`;
|
|
226
|
+
}
|
package/dist/server/http.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { ConnectRouter } from "@connectrpc/connect";
|
|
2
2
|
import { type HttpRequest, type HttpReply, type SocketIn, type SocketOut, type StreamFrame } from "../_proto/ankka/protocol/v1/endpoint_pb.ts";
|
|
3
|
+
import { type Caller, type Principal } from "../context.ts";
|
|
4
|
+
import { type Schema } from "../schema.ts";
|
|
3
5
|
import type { ServerContext } from "./server.ts";
|
|
6
|
+
/** The principal the sidecar verified, for a route or a tool call; never the credential. */
|
|
7
|
+
export declare function principalOf(req: Pick<HttpRequest, "principal">): Principal | null;
|
|
8
|
+
/** The caller the sidecar established; a sidecar that predates protocol 1.1 sends none, which reads as local. */
|
|
9
|
+
export declare function callerOf(req: Pick<HttpRequest, "caller">): Caller;
|
|
10
|
+
/** A path parameter as its declared schema: strings as they are, scalars through their text codec. */
|
|
11
|
+
export declare function parseParam(name: string, text: string, shape: Schema | undefined): unknown;
|
|
4
12
|
export interface HttpDispatcher {
|
|
5
13
|
handle(req: HttpRequest): Promise<HttpReply>;
|
|
6
14
|
handleStream(req: HttpRequest): AsyncIterable<StreamFrame>;
|
package/dist/server/http.js
CHANGED
|
@@ -11,7 +11,7 @@ import { Headers, Query, metadataFromProto, withRequest } from "../context.js";
|
|
|
11
11
|
import { CommandError, ErrorCode, httpStatusOf } from "../effects/common.js";
|
|
12
12
|
import { DecodingError } from "../json.js";
|
|
13
13
|
import { errorCodeToProto } from "../kinds.js";
|
|
14
|
-
import { HttpProblem } from "../routes.js";
|
|
14
|
+
import { HttpProblem, RawReply } from "../routes.js";
|
|
15
15
|
import { done, resolve } from "../schema.js";
|
|
16
16
|
import { SocketClosed, socketOf } from "../socket.js";
|
|
17
17
|
import { AsyncQueue } from "./queue.js";
|
|
@@ -25,7 +25,8 @@ function failureReply(message) {
|
|
|
25
25
|
function messageOf(e) {
|
|
26
26
|
return e instanceof Error ? e.message : String(e);
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
/** The principal the sidecar verified, for a route or a tool call; never the credential. */
|
|
29
|
+
export function principalOf(req) {
|
|
29
30
|
const p = req.principal;
|
|
30
31
|
if (!p)
|
|
31
32
|
return null;
|
|
@@ -40,7 +41,7 @@ function principalOf(req) {
|
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
43
|
/** The caller the sidecar established; a sidecar that predates protocol 1.1 sends none, which reads as local. */
|
|
43
|
-
function callerOf(req) {
|
|
44
|
+
export function callerOf(req) {
|
|
44
45
|
const kind = req.caller?.kind;
|
|
45
46
|
switch (kind?.case) {
|
|
46
47
|
case "gateway":
|
|
@@ -54,7 +55,7 @@ function callerOf(req) {
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
/** A path parameter as its declared schema: strings as they are, scalars through their text codec. */
|
|
57
|
-
function parseParam(name, text, shape) {
|
|
58
|
+
export function parseParam(name, text, shape) {
|
|
58
59
|
if (!shape)
|
|
59
60
|
return text;
|
|
60
61
|
const r = resolve(shape);
|
|
@@ -71,6 +72,9 @@ function parseParam(name, text, shape) {
|
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
function encodeResult(route, result) {
|
|
75
|
+
if (result instanceof RawReply) {
|
|
76
|
+
return create(HttpReplySchema, { message: { case: "response", value: { status: result.status, contentType: result.contentType, body: result.body, headers: [] } } });
|
|
77
|
+
}
|
|
74
78
|
if (result === undefined || result === done) {
|
|
75
79
|
return create(HttpReplySchema, { message: { case: "response", value: { status: 204, contentType: "", body: new Uint8Array(), headers: [] } } });
|
|
76
80
|
}
|
|
@@ -93,6 +97,8 @@ export function createHttpDispatcher(ctx) {
|
|
|
93
97
|
if (!instance) {
|
|
94
98
|
instance = new endpoint.cls();
|
|
95
99
|
instance._bindClient(ctx.client);
|
|
100
|
+
if (ctx.storage)
|
|
101
|
+
instance.storage = ctx.storage;
|
|
96
102
|
instances.set(endpoint.id, instance);
|
|
97
103
|
}
|
|
98
104
|
try {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ConnectRouter } from "@connectrpc/connect";
|
|
2
|
+
import { type McpToolReply, type McpToolRequest } from "../_proto/ankka/protocol/v1/mcp_pb.ts";
|
|
3
|
+
import { type Shape } from "../codec.ts";
|
|
4
|
+
import type { ServerContext } from "./server.ts";
|
|
5
|
+
/** What a tool answered: its text, and the same as structured content when it is a JSON object. */
|
|
6
|
+
export interface ToolAnswer {
|
|
7
|
+
readonly text: string;
|
|
8
|
+
readonly structuredJson?: string;
|
|
9
|
+
}
|
|
10
|
+
/** A tool's result as text and structured content, encoded with its reply shape when it has one. */
|
|
11
|
+
export declare function answerOf(value: unknown, reply: Shape<unknown> | undefined): ToolAnswer;
|
|
12
|
+
export interface McpDispatcher {
|
|
13
|
+
invokeTool(req: McpToolRequest): Promise<McpToolReply>;
|
|
14
|
+
}
|
|
15
|
+
/** The behaviour behind `Mcp.InvokeTool`, over the registry's MCP endpoints. */
|
|
16
|
+
export declare function createMcpDispatcher(ctx: ServerContext): McpDispatcher;
|
|
17
|
+
export declare function mcpRoutes(router: ConnectRouter, ctx: ServerContext): void;
|