ankka 0.12.0 → 0.14.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.
Files changed (65) hide show
  1. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +594 -0
  2. package/dist/_proto/ankka/protocol/v1/client_pb.js +172 -30
  3. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +1 -1
  4. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +134 -0
  5. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +51 -36
  6. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +24 -0
  7. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +1 -1
  8. package/dist/_proto/ankka/protocol/v1/mcp_pb.d.ts +120 -0
  9. package/dist/_proto/ankka/protocol/v1/mcp_pb.js +29 -0
  10. package/dist/_proto/ankka/protocol/v1/timed_action_pb.js +1 -1
  11. package/dist/_proto/ankka/protocol/v1/view_pb.js +1 -1
  12. package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +12 -0
  13. package/dist/_proto/ankka/protocol/v1/wasm_pb.js +1 -1
  14. package/dist/agent.d.ts +10 -0
  15. package/dist/agent.js +20 -0
  16. package/dist/autonomous.d.ts +5 -0
  17. package/dist/autonomous.js +10 -0
  18. package/dist/client-keys.d.ts +89 -0
  19. package/dist/client-keys.js +182 -0
  20. package/dist/consumer.d.ts +5 -0
  21. package/dist/consumer.js +10 -0
  22. package/dist/context.d.ts +11 -3
  23. package/dist/context.js +20 -4
  24. package/dist/endpoint.d.ts +10 -0
  25. package/dist/endpoint.js +20 -0
  26. package/dist/graph.d.ts +5 -0
  27. package/dist/graph.js +10 -0
  28. package/dist/index.d.ts +6 -1
  29. package/dist/index.js +5 -0
  30. package/dist/mcp-endpoint.d.ts +144 -0
  31. package/dist/mcp-endpoint.js +256 -0
  32. package/dist/routes.d.ts +25 -2
  33. package/dist/routes.js +29 -2
  34. package/dist/server/discovery.d.ts +7 -0
  35. package/dist/server/discovery.js +26 -2
  36. package/dist/server/eventSourced.js +3 -3
  37. package/dist/server/http.d.ts +8 -0
  38. package/dist/server/http.js +10 -4
  39. package/dist/server/mcp.d.ts +17 -0
  40. package/dist/server/mcp.js +157 -0
  41. package/dist/server/server.d.ts +11 -1
  42. package/dist/server/server.js +19 -0
  43. package/dist/server/storageEndpoint.d.ts +14 -0
  44. package/dist/server/storageEndpoint.js +82 -0
  45. package/dist/service.d.ts +6 -1
  46. package/dist/service.js +47 -4
  47. package/dist/sigv4.d.ts +47 -0
  48. package/dist/sigv4.js +149 -0
  49. package/dist/spec.d.ts +8 -2
  50. package/dist/spec.js +53 -3
  51. package/dist/storage.d.ts +288 -0
  52. package/dist/storage.js +1081 -0
  53. package/dist/testkit/integration.d.ts +9 -0
  54. package/dist/testkit/integration.js +5 -1
  55. package/dist/testkit/kinds.d.ts +16 -4
  56. package/dist/testkit/kinds.js +36 -14
  57. package/dist/testkit/unit.d.ts +4 -1
  58. package/dist/testkit/unit.js +8 -4
  59. package/dist/timedAction.d.ts +10 -0
  60. package/dist/timedAction.js +20 -0
  61. package/dist/version.d.ts +1 -1
  62. package/dist/version.js +1 -1
  63. package/dist/workflow.d.ts +8 -0
  64. package/dist/workflow.js +15 -0
  65. package/package.json +2 -2
@@ -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
- function principalOf(req) {
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;
@@ -0,0 +1,157 @@
1
+ // A tool of one of this process's MCP endpoints (protocol 1.20). The platform answered the protocol,
2
+ // verified the credential and checked the arguments against the tool's schema; here the tool runs, as
3
+ // a route does in `http.ts`: the call in the async context (`this.request`, with the principal and the
4
+ // caller, never the credential), the run awaited, and its outcome answered as `ok`, `error` (the tool
5
+ // error a client is shown) or `failure` (this process could not run it at all).
6
+ //
7
+ // `createMcpDispatcher` is the whole behaviour; the Connect servicer and the unit tests both call it.
8
+ import { create } from "@bufbuild/protobuf";
9
+ import { Mcp, McpToolReplySchema } from "../_proto/ankka/protocol/v1/mcp_pb.js";
10
+ import { codecFor, isCodec } from "../codec.js";
11
+ import { Headers, Query, metadataFromProto, withRequest } from "../context.js";
12
+ import { CommandError, ErrorCode } from "../effects/common.js";
13
+ import { decodeJsonValue, reviver } from "../json.js";
14
+ import { errorCodeToProto } from "../kinds.js";
15
+ import { HttpProblem } from "../routes.js";
16
+ import { done } from "../schema.js";
17
+ import { callerOf, parseParam, principalOf } from "./http.js";
18
+ const utf8 = new TextEncoder();
19
+ const utf8Decoder = new TextDecoder();
20
+ function messageOf(e) {
21
+ return e instanceof Error ? e.message : String(e);
22
+ }
23
+ function isPlainObject(x) {
24
+ return typeof x === "object" && x !== null && !Array.isArray(x);
25
+ }
26
+ function structured(text) {
27
+ try {
28
+ return isPlainObject(JSON.parse(text)) ? { text, structuredJson: text } : { text };
29
+ }
30
+ catch {
31
+ return { text };
32
+ }
33
+ }
34
+ /** A tool's result as text and structured content, encoded with its reply shape when it has one. */
35
+ export function answerOf(value, reply) {
36
+ if (value === undefined || value === done)
37
+ return { text: "done" };
38
+ if (typeof value === "string" && (reply === undefined || codecFor(reply).contentType === "text/plain"))
39
+ return { text: value };
40
+ if (reply === undefined)
41
+ return structured(JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v)));
42
+ const codec = codecFor(reply);
43
+ const text = utf8Decoder.decode(codec.encode(value));
44
+ return codec.contentType === "application/json" ? structured(text) : { text };
45
+ }
46
+ /** An argument as a handler's input: through its codec when it has one, else its schema. */
47
+ function decodeArgument(shape, raw) {
48
+ if (isCodec(shape))
49
+ return shape.decode(utf8.encode(JSON.stringify(raw)));
50
+ return decodeJsonValue(shape, raw);
51
+ }
52
+ class ToolRefusal extends Error {
53
+ }
54
+ /** The behaviour behind `Mcp.InvokeTool`, over the registry's MCP endpoints. */
55
+ export function createMcpDispatcher(ctx) {
56
+ const instances = new Map();
57
+ function instanceOf(id, cls) {
58
+ let instance = instances.get(id);
59
+ if (!instance) {
60
+ instance = new cls();
61
+ instance._bindClient(ctx.client);
62
+ instances.set(id, instance);
63
+ }
64
+ return instance;
65
+ }
66
+ async function runHandler(run, args, self) {
67
+ const id = args[run.id];
68
+ if (typeof id !== "string" || id === "")
69
+ throw new ToolRefusal(`parameter '${run.id}': expected a non-empty string`);
70
+ const handler = run.handler;
71
+ let input;
72
+ if (run.input?.kind === "merge") {
73
+ const { [run.id]: _id, ...rest } = args;
74
+ input = decodeArgument(handler.input, rest);
75
+ }
76
+ else if (run.input?.kind === "argument") {
77
+ input = decodeArgument(handler.input, args[run.input.name]);
78
+ }
79
+ const client = self.client;
80
+ const calls = run.componentKind === "event-sourced"
81
+ ? client.forEventSourcedEntity(run.componentId, id)
82
+ : run.componentKind === "key-value"
83
+ ? client.forKeyValueEntity(run.componentId, id)
84
+ : client.forWorkflow(run.componentId, id);
85
+ const result = await calls.call(handler.name, handler.input, handler.reply).invoke(input);
86
+ return answerOf(result, handler.reply);
87
+ }
88
+ async function runRoute(run, args, call) {
89
+ const endpoint = ctx.registry.endpoint(run.endpointId);
90
+ if (!endpoint)
91
+ throw new Error(`the endpoint ${run.endpointId} is not registered`);
92
+ const route = run.route;
93
+ const params = {};
94
+ for (const name of run.paramNames) {
95
+ const raw = args[name];
96
+ if (typeof raw !== "string" && typeof raw !== "number")
97
+ throw new ToolRefusal(`parameter '${name}': expected a string`);
98
+ params[name] = parseParam(name, String(raw), route.paramShapes[name]);
99
+ }
100
+ const body = route.body ? codecFor(route.body).decode(utf8.encode(JSON.stringify(args.body ?? {}))) : undefined;
101
+ const request = Object.freeze({ ...call, params: Object.freeze(params) });
102
+ const instance = instanceOf(endpoint.id, endpoint.cls);
103
+ const result = await withRequest(request, () => route.run(instance, request, body));
104
+ return answerOf(result, route.reply);
105
+ }
106
+ async function run(endpoint, tool, req) {
107
+ const parsed = req.argumentsJson.trim() === "" ? {} : JSON.parse(req.argumentsJson, reviver);
108
+ if (!isPlainObject(parsed))
109
+ throw new ToolRefusal("the arguments must be an object");
110
+ const args = parsed;
111
+ const self = instanceOf(endpoint.id, endpoint.cls);
112
+ const call = Object.freeze({
113
+ params: Object.freeze({}),
114
+ query: new Query([]),
115
+ headers: new Headers([]),
116
+ principal: principalOf(req),
117
+ metadata: metadataFromProto(req.metadata),
118
+ caller: callerOf(req),
119
+ });
120
+ return withRequest(call, async () => {
121
+ switch (tool.run.kind) {
122
+ case "function":
123
+ return answerOf(await tool.run.run(decodeArgument(tool.run.input, args), self), tool.run.reply);
124
+ case "handler":
125
+ return runHandler(tool.run, args, self);
126
+ case "route":
127
+ return runRoute(tool.run, args, call);
128
+ }
129
+ });
130
+ }
131
+ return {
132
+ async invokeTool(req) {
133
+ const endpoint = ctx.registry.mcpEndpoint(req.endpointId);
134
+ const tool = endpoint?.tools.find((t) => t.name === req.tool);
135
+ if (!endpoint || !tool) {
136
+ return create(McpToolReplySchema, {
137
+ reply: { case: "failure", value: { commandId: 0n, error: { message: `no tool ${req.endpointId}/${req.tool} is registered`, code: errorCodeToProto(ErrorCode.NotFound) } } },
138
+ });
139
+ }
140
+ try {
141
+ const answer = await run(endpoint, tool, req);
142
+ return create(McpToolReplySchema, { reply: { case: "ok", value: { text: answer.text, ...(answer.structuredJson !== undefined ? { structuredJson: answer.structuredJson } : {}) } } });
143
+ }
144
+ catch (e) {
145
+ // A refusal, a decoding failure or a throw: the tool error the client is shown, which a model can act on.
146
+ if (!(e instanceof CommandError || e instanceof HttpProblem || e instanceof ToolRefusal)) {
147
+ ctx.log(`ankka: tool ${endpoint.path} ${tool.name} threw: ${e instanceof Error ? `${e.name}: ${e.message}` : String(e)}`);
148
+ }
149
+ return create(McpToolReplySchema, { reply: { case: "error", value: messageOf(e) } });
150
+ }
151
+ },
152
+ };
153
+ }
154
+ export function mcpRoutes(router, ctx) {
155
+ const dispatcher = createMcpDispatcher(ctx);
156
+ router.service(Mcp, { invokeTool: (req) => dispatcher.invokeTool(req) });
157
+ }
@@ -1,5 +1,6 @@
1
- import type { Registry } from "../service.ts";
1
+ import { Registry } from "../service.ts";
2
2
  import type { ComponentClient } from "../client.ts";
3
+ import { type Buckets } from "../storage.ts";
3
4
  export interface ServerOptions {
4
5
  /** `127.0.0.1` by default; `0.0.0.0` for the testkit; anything else is refused. */
5
6
  readonly host?: string;
@@ -8,12 +9,21 @@ export interface ServerOptions {
8
9
  /** The largest message accepted from the sidecar; 64 MiB by default. */
9
10
  readonly readMaxBytes?: number;
10
11
  readonly log?: (message: string) => void;
12
+ /**
13
+ * The service's buckets, in place of the store `ANKKA_STORAGE_BACKEND` and the `ANKKA_S3_*` variables
14
+ * choose: the integration testkit's in-memory store.
15
+ */
16
+ readonly storage?: Buckets;
17
+ /** Where `service.json` is read from for the buckets it declares; the working directory by default. */
18
+ readonly serviceDirectory?: string;
11
19
  }
12
20
  /** What every servicer needs. */
13
21
  export interface ServerContext {
14
22
  readonly registry: Registry;
15
23
  readonly client: ComponentClient;
16
24
  readonly log: (message: string) => void;
25
+ /** The buckets every endpoint instance is given; the process's store when absent. */
26
+ readonly storage?: Buckets;
17
27
  }
18
28
  export declare class Server {
19
29
  #private;
@@ -4,6 +4,9 @@
4
4
  // the integration testkit, whose sidecar reaches this process from a container.
5
5
  import { createServer } from "node:http2";
6
6
  import { connectNodeAdapter } from "@connectrpc/connect-node";
7
+ import { Registry } from "../service.js";
8
+ import { _setProcessStorage, chooseStorage, DirectoryBuckets } from "../storage.js";
9
+ import { DirectoryStorageEndpoint } from "./storageEndpoint.js";
7
10
  import { renderSpec } from "../spec.js";
8
11
  import { discoveryRoutes } from "./discovery.js";
9
12
  import { eventSourcedRoutes } from "./eventSourced.js";
@@ -12,6 +15,13 @@ import { workflowRoutes } from "./workflow.js";
12
15
  import { statelessRoutes } from "./stateless.js";
13
16
  import { agentRoutes } from "./agent.js";
14
17
  import { httpRoutes } from "./http.js";
18
+ import { mcpRoutes } from "./mcp.js";
19
+ /** The registry with the directory store's endpoint, which serves the URLs that store signs. */
20
+ function withStorageEndpoint(registry) {
21
+ const cls = DirectoryStorageEndpoint;
22
+ const endpoint = Object.freeze({ id: cls.name, cls, prefix: cls.prefix, acl: cls.acl, routes: new Map(Object.entries(cls.routes)) });
23
+ return new Registry(registry.components, new Map([...registry.endpoints, [endpoint.id, endpoint]]));
24
+ }
15
25
  const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "0.0.0.0", "::"]);
16
26
  export class Server {
17
27
  #context;
@@ -39,6 +49,14 @@ export class Server {
39
49
  const port = this.#options.port ?? Number(process.env.ANKKA_PROCESS_PORT ?? 9010);
40
50
  if (!Number.isInteger(port) || port < 0 || port > 65535)
41
51
  throw new Error(`not a port: ${String(port)}`);
52
+ // The service's buckets: the store given, or the one the environment chooses. A directory store's
53
+ // URLs are answered by an endpoint this process declares at /_ankka/storage.
54
+ const given = this.#options.storage;
55
+ const chosen = given ? { backend: "given", buckets: given } : chooseStorage(process.env, { ...(this.#options.serviceDirectory ? { serviceDirectory: this.#options.serviceDirectory } : {}) });
56
+ _setProcessStorage(chosen.buckets);
57
+ const registry = chosen.buckets instanceof DirectoryBuckets ? withStorageEndpoint(this.#context.registry) : this.#context.registry;
58
+ this.#context = { ...this.#context, registry, storage: chosen.buckets };
59
+ this.#context.log(`ankka: object storage is ${chosen.backend}${chosen.buckets instanceof DirectoryBuckets ? ` at ${chosen.buckets.root}` : ""}`);
42
60
  const handler = connectNodeAdapter({
43
61
  routes: (router) => {
44
62
  discoveryRoutes(router, () => renderSpec(this.#context.registry), this.#context.log);
@@ -48,6 +66,7 @@ export class Server {
48
66
  statelessRoutes(router, this.#context);
49
67
  agentRoutes(router, this.#context);
50
68
  httpRoutes(router, this.#context);
69
+ mcpRoutes(router, this.#context);
51
70
  },
52
71
  shutdownSignal: this.#shutdown.signal,
53
72
  readMaxBytes: this.#options.readMaxBytes ?? 64 * 1024 * 1024,
@@ -0,0 +1,14 @@
1
+ import { Endpoint } from "../endpoint.ts";
2
+ import { Acl, RawReply, type RouteRef } from "../routes.ts";
3
+ type Params = Readonly<Record<string, string>>;
4
+ export declare class DirectoryStorageEndpoint extends Endpoint {
5
+ #private;
6
+ static readonly prefix = "/_ankka/storage";
7
+ static readonly acl: Acl;
8
+ /** @internal The SDK's own endpoint under `/_ankka`, which registration admits. */
9
+ static readonly _sdkReserved = true;
10
+ static readonly routes: Readonly<Record<string, RouteRef<DirectoryStorageEndpoint, any, any, any>>>;
11
+ read(params: Params, depth: number): Promise<RawReply>;
12
+ keep(params: Params, depth: number, body: Uint8Array): Promise<RawReply>;
13
+ }
14
+ export {};
@@ -0,0 +1,82 @@
1
+ // What a TypeScript service on a developer's machine serves at `/_ankka/storage/<bucket>/<name>` while its
2
+ // store is a directory (feature 055, research R12): the signed URLs and public addresses that store
3
+ // makes, answered through the sidecar like any route, so a browser reads and keeps through them as it
4
+ // would through an object store's. The runtime's `DirectoryStorageEndpoint` says the same in Scala.
5
+ //
6
+ // The token is the whole of the authority, as a signature is on an object store: a `GET` reads with a
7
+ // token for `read`, or with none on a public bucket; a `PUT` keeps with a token for `keep`. Anything
8
+ // else is `403`, and an object not there `404`. The sidecar matches a route by whole segments and an
9
+ // object's name holds slashes, so one route is declared per depth, up to `MAX_ADDRESS_SEGMENTS`; a
10
+ // deeper name travels as one segment with its slashes encoded.
11
+ import { binaryCodecs } from "../codec.js";
12
+ import { Endpoint } from "../endpoint.js";
13
+ import { Acl, get, HttpProblem, put, RawReply } from "../routes.js";
14
+ import { DirectoryBuckets, MAX_ADDRESS_SEGMENTS, STORAGE_PATH } from "../storage.js";
15
+ /** A segment as the sidecar forwards it, encoded; one that is not valid encoding is taken as it is. */
16
+ function decoded(segment) {
17
+ try {
18
+ return decodeURIComponent(segment);
19
+ }
20
+ catch {
21
+ return segment;
22
+ }
23
+ }
24
+ /** The bucket's segment and the object's name a request's path parameters spell. */
25
+ function addressed(params, depth) {
26
+ const parts = [];
27
+ for (let i = 1; i <= depth; i++)
28
+ parts.push(decoded(params[`n${i}`] ?? ""));
29
+ return { segment: decoded(params.bucket ?? ""), objectName: parts.join("/") };
30
+ }
31
+ const NOWHERE = "no object at this address";
32
+ const UNSIGNED = "this address is not signed for that, or its signature has expired";
33
+ export class DirectoryStorageEndpoint extends Endpoint {
34
+ static prefix = STORAGE_PATH;
35
+ static acl = Acl.allowAll;
36
+ /** @internal The SDK's own endpoint under `/_ankka`, which registration admits. */
37
+ static _sdkReserved = true;
38
+ static routes = (() => {
39
+ const table = {};
40
+ for (let depth = 1; depth <= MAX_ADDRESS_SEGMENTS; depth++) {
41
+ const template = "/{bucket}" + Array.from({ length: depth }, (_, i) => `/{n${i + 1}}`).join("");
42
+ table[`read${depth}`] = get(template, binaryCodecs.bytes, (ep, req) => ep.read(req.params, depth));
43
+ table[`keep${depth}`] = put(template, binaryCodecs.bytes, binaryCodecs.bytes, (ep, req, body) => ep.keep(req.params, depth, body));
44
+ }
45
+ return Object.freeze(table);
46
+ })();
47
+ #store(segment) {
48
+ const store = this.storage;
49
+ if (!(store instanceof DirectoryBuckets))
50
+ throw new HttpProblem(404, NOWHERE);
51
+ const found = store.bySegment(segment);
52
+ if (!found)
53
+ throw new HttpProblem(404, NOWHERE);
54
+ return { store, bucket: found.bucket, segment: found.segment };
55
+ }
56
+ #signedFor(store, segment, objectName, access) {
57
+ const q = this.request.query;
58
+ const expires = Number(q.get("expires") ?? "");
59
+ const token = q.get("token");
60
+ return q.get("access") === access && token !== null && Number.isInteger(expires) && store.admits(segment, objectName, access, expires, token);
61
+ }
62
+ async read(params, depth) {
63
+ const { segment, objectName } = addressed(params, depth);
64
+ const { store, bucket } = this.#store(segment);
65
+ const open = bucket.exposure === "public" && bucket.name !== undefined;
66
+ if (!open && !this.#signedFor(store, segment, objectName, "read"))
67
+ throw new HttpProblem(403, UNSIGNED);
68
+ const object = await bucket.get(objectName);
69
+ if (!object)
70
+ throw new HttpProblem(404, `no object '${objectName}'`);
71
+ return new RawReply(200, object.contentType, await object.bytes());
72
+ }
73
+ async keep(params, depth, body) {
74
+ const { segment, objectName } = addressed(params, depth);
75
+ const { store, bucket } = this.#store(segment);
76
+ if (!this.#signedFor(store, segment, objectName, "keep"))
77
+ throw new HttpProblem(403, UNSIGNED);
78
+ const contentType = this.request.headers.get("content-type") ?? "application/octet-stream";
79
+ await bucket.put(objectName, body, { contentType });
80
+ return new RawReply(200, "text/plain; charset=utf-8", new Uint8Array());
81
+ }
82
+ }
package/dist/service.d.ts CHANGED
@@ -13,6 +13,7 @@ import { Agent, type AgentClass } from "./agent.ts";
13
13
  import { type McpServerRef, type ResultGuardrailRef } from "./mcp.ts";
14
14
  import { AutonomousAgent, type AutonomousAgentClass, type TaskType } from "./autonomous.ts";
15
15
  import { Endpoint, type EndpointClass } from "./endpoint.ts";
16
+ import { McpEndpoint, type McpEndpointClass, type RegisteredMcpEndpoint } from "./mcp-endpoint.ts";
16
17
  import type { GuardrailRef, HandlerRef, ToolRef } from "./handlers.ts";
17
18
  import type { RouteRef, Acl } from "./routes.ts";
18
19
  import { ComponentClient } from "./client.ts";
@@ -150,7 +151,10 @@ export interface RegisteredEndpoint {
150
151
  export declare class Registry {
151
152
  readonly components: ReadonlyMap<string, RegisteredComponent>;
152
153
  readonly endpoints: ReadonlyMap<string, RegisteredEndpoint>;
153
- constructor(components: ReadonlyMap<string, RegisteredComponent>, endpoints: ReadonlyMap<string, RegisteredEndpoint>);
154
+ /** The MCP endpoints (1.20), by id, their references resolved. */
155
+ readonly mcpEndpoints: ReadonlyMap<string, RegisteredMcpEndpoint>;
156
+ constructor(components: ReadonlyMap<string, RegisteredComponent>, endpoints: ReadonlyMap<string, RegisteredEndpoint>, mcpEndpoints?: ReadonlyMap<string, RegisteredMcpEndpoint>);
157
+ mcpEndpoint(id: string): RegisteredMcpEndpoint | undefined;
154
158
  component(id: string): RegisteredComponent | undefined;
155
159
  /** The component `id` when it is of `kind`. */
156
160
  of<K extends RegisteredComponent["kind"]>(kind: K, id: string): Extract<RegisteredComponent, {
@@ -185,6 +189,7 @@ export declare class ServiceBuilder {
185
189
  register<C extends TimedAction>(cls: TimedActionClass<C>): this;
186
190
  register<C extends Agent>(cls: AgentClass<C>): this;
187
191
  register<C extends AutonomousAgent>(cls: AutonomousAgentClass<C>): this;
192
+ register<C extends McpEndpoint>(cls: McpEndpointClass<C>): this;
188
193
  register<C extends Endpoint>(cls: EndpointClass<C>): this;
189
194
  /** Validates every registration and returns the registry, or throws a `RegistrationError` naming every problem. */
190
195
  validate(): Registry;
package/dist/service.js CHANGED
@@ -18,7 +18,8 @@ import { Agent } from "./agent.js";
18
18
  import { serverProblems, TOOL_PREFIX } from "./mcp.js";
19
19
  import { AutonomousAgent, COMPLETE_TASK, FAIL_TASK } from "./autonomous.js";
20
20
  import { Endpoint } from "./endpoint.js";
21
- import { isAcl } from "./routes.js";
21
+ import { McpEndpoint, registerMcpEndpoint } from "./mcp-endpoint.js";
22
+ import { isAcl, isEndpointAcl } from "./routes.js";
22
23
  import { ComponentClient } from "./client.js";
23
24
  import { Server } from "./server/server.js";
24
25
  import { renderSpec } from "./spec.js";
@@ -26,11 +27,17 @@ import { renderSpec } from "./spec.js";
26
27
  export class Registry {
27
28
  components;
28
29
  endpoints;
29
- constructor(components, endpoints) {
30
+ /** The MCP endpoints (1.20), by id, their references resolved. */
31
+ mcpEndpoints;
32
+ constructor(components, endpoints, mcpEndpoints = new Map()) {
30
33
  this.components = components;
31
34
  this.endpoints = endpoints;
35
+ this.mcpEndpoints = mcpEndpoints;
32
36
  Object.freeze(this);
33
37
  }
38
+ mcpEndpoint(id) {
39
+ return this.mcpEndpoints.get(id);
40
+ }
34
41
  component(id) {
35
42
  return this.components.get(id);
36
43
  }
@@ -83,6 +90,7 @@ export class ServiceBuilder {
83
90
  const components = new Map();
84
91
  const endpoints = new Map();
85
92
  const prefixes = new Map();
93
+ const mcpClasses = [];
86
94
  const add = (r, cls) => {
87
95
  if (!r)
88
96
  return;
@@ -112,6 +120,9 @@ export class ServiceBuilder {
112
120
  add(registerAgent(cls, problems), cls);
113
121
  else if (extendsBase(cls, AutonomousAgent))
114
122
  add(registerAutonomousAgent(cls, problems), cls);
123
+ // Before Endpoint: an MCP endpoint is one, served by the platform rather than by routes.
124
+ else if (extendsBase(cls, McpEndpoint))
125
+ mcpClasses.push(cls);
115
126
  else if (extendsBase(cls, Endpoint)) {
116
127
  const ep = cls;
117
128
  if (typeof ep.prefix === "string") {
@@ -129,12 +140,35 @@ export class ServiceBuilder {
129
140
  }
130
141
  }
131
142
  else {
132
- problems.push(`${nameOf(cls)} is not a component class: it must extend EventSourcedEntity, KeyValueEntity, Workflow, View, KeyedView, Consumer, GraphConsumer, TimedAction, Agent, AutonomousAgent or Endpoint`);
143
+ problems.push(`${nameOf(cls)} is not a component class: it must extend EventSourcedEntity, KeyValueEntity, Workflow, View, KeyedView, Consumer, GraphConsumer, TimedAction, Agent, AutonomousAgent, Endpoint or McpEndpoint`);
144
+ }
145
+ }
146
+ // MCP endpoints last: their references name the components and endpoints registered above.
147
+ const mcpEndpoints = new Map();
148
+ const mcpPaths = new Map();
149
+ for (const cls of mcpClasses) {
150
+ const r = registerMcpEndpoint(cls, components, endpoints, problems);
151
+ if (!r)
152
+ continue;
153
+ const other = mcpPaths.get(r.path);
154
+ if (other)
155
+ problems.push(`MCP endpoints ${other} and ${r.id} share the path ${r.path}`);
156
+ mcpPaths.set(r.path, r.id);
157
+ for (const e of endpoints.values()) {
158
+ for (const route of e.routes.values()) {
159
+ const full = route.template === "/" ? e.prefix : e.prefix === "/" ? route.template : `${e.prefix}${route.template}`;
160
+ if (full === r.path)
161
+ problems.push(`${r.id}: its path ${r.path} is also the route ${route.method} ${full} of ${e.id}`);
162
+ }
133
163
  }
164
+ if (mcpEndpoints.has(r.id))
165
+ problems.push(`two MCP endpoints are named ${r.id}`);
166
+ else
167
+ mcpEndpoints.set(r.id, r);
134
168
  }
135
169
  if (problems.length > 0)
136
170
  throw new RegistrationError(problems);
137
- return new Registry(components, endpoints);
171
+ return new Registry(components, endpoints, mcpEndpoints);
138
172
  }
139
173
  /** The discovery `Spec` for what is registered, without listening. */
140
174
  spec() {
@@ -635,9 +669,16 @@ function registerEndpoint(cls, problems) {
635
669
  if (typeof cls.prefix !== "string" || !cls.prefix.startsWith("/") || (cls.prefix.length > 1 && cls.prefix.endsWith("/"))) {
636
670
  fail(`needs a static prefix starting with "/" and not ending with one, not ${JSON.stringify(cls.prefix)}`);
637
671
  }
672
+ else if ((cls.prefix === "/_ankka" || cls.prefix.startsWith("/_ankka/")) && cls._sdkReserved !== true) {
673
+ // The SDK's own directory-store endpoint is the one exception, as the sidecar admits it alone.
674
+ fail(`prefix '${cls.prefix}' is under /_ankka, which is the runtime's`);
675
+ }
638
676
  if (!isAcl(cls.acl)) {
639
677
  fail(`needs a static acl — Acl.allowAll, Acl.denyAll, Acl.authenticated or Acl.allowCallers(...). An unstated access rule is a decision nobody made`);
640
678
  }
679
+ else if (!isEndpointAcl(cls.acl)) {
680
+ fail("admits client keys or states Acl.anyOf, which only an MCP endpoint can: extend McpEndpoint");
681
+ }
641
682
  const routes = new Map();
642
683
  const seen = new Map();
643
684
  if (typeof cls.routes !== "object" || cls.routes === null) {
@@ -657,6 +698,8 @@ function registerEndpoint(cls, problems) {
657
698
  seen.set(key, id);
658
699
  if (r.acl !== undefined && !isAcl(r.acl))
659
700
  fail(`routes.${id}: unknown acl ${JSON.stringify(r.acl)}`);
701
+ else if (r.acl !== undefined && !isEndpointAcl(r.acl))
702
+ fail(`routes.${id} admits client keys or states Acl.anyOf, which only an MCP endpoint can`);
660
703
  routes.set(id, r);
661
704
  }
662
705
  }
@@ -0,0 +1,47 @@
1
+ /** A key pair. Never printed whole: `toString` hides the secret. */
2
+ export declare class Credentials {
3
+ #private;
4
+ readonly accessKey: string;
5
+ constructor(accessKey: string, secretKey: string);
6
+ /** @internal */
7
+ get _secretKey(): string;
8
+ toString(): string;
9
+ toJSON(): string;
10
+ }
11
+ export declare const ALGORITHM = "AWS4-HMAC-SHA256";
12
+ /** The payload hash a put sends so its body is streamed once and never hashed. */
13
+ export declare const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
14
+ export declare function sha256Hex(data: Uint8Array | string): string;
15
+ export declare function hmac(key: Uint8Array | string, data: string): Buffer;
16
+ /** The hash of an empty payload. */
17
+ export declare const EMPTY_PAYLOAD: string;
18
+ /** RFC 3986 encoding as SigV4 states it: unreserved characters kept, everything else `%XX` of its UTF-8 bytes. */
19
+ export declare function encode(text: string, encodeSlash: boolean): string;
20
+ /** One request: the endpoint's scheme and authority, a decoded path and decoded query pairs. */
21
+ export declare class SignedRequest {
22
+ readonly method: string;
23
+ readonly endpoint: URL;
24
+ readonly path: string;
25
+ readonly query: readonly (readonly [string, string])[];
26
+ constructor(method: string, endpoint: URL | string, path: string, query?: readonly (readonly [string, string])[]);
27
+ /** The `host` header's value: the host, with the port when the endpoint names one. */
28
+ get host(): string;
29
+ get canonicalPath(): string;
30
+ get canonicalQuery(): string;
31
+ /** The URL the request is sent to: the same encoding as the one signed. */
32
+ get url(): string;
33
+ withQuery(more: readonly (readonly [string, string])[]): SignedRequest;
34
+ }
35
+ /** `20150830T123600Z`. */
36
+ export declare function amzDate(now: Date): string;
37
+ /**
38
+ * The headers to send with `request` so the store believes it: `x-amz-date` and `Authorization`.
39
+ * `headers` are the ones to sign beside `host` and `x-amz-date` — an S3 request includes
40
+ * `x-amz-content-sha256` among them — and are sent as given.
41
+ */
42
+ export declare function sign(request: SignedRequest, headers: Readonly<Record<string, string>>, payloadHash: string, credentials: Credentials, region: string, service: string, now: Date): {
43
+ "x-amz-date": string;
44
+ Authorization: string;
45
+ };
46
+ /** A URL anyone may use, until it expires, for `request`'s method on its path. Signs `host` alone. */
47
+ export declare function presign(request: SignedRequest, credentials: Credentials, region: string, service: string, expiresSeconds: number, now: Date): string;