@absolutejs/mcp 0.5.1 → 0.7.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/README.md CHANGED
@@ -50,6 +50,13 @@ tasks: {
50
50
  }
51
51
  ```
52
52
 
53
+ For multi-instance production deployments, use
54
+ `createPostgresMcpTaskStore()` and `createPostgresMcpSessionStore()` after
55
+ applying `mcpPostgresSchemaSql()`. Task updates and cancellation protect
56
+ terminal states in the database, task reads enforce TTL, and session access
57
+ atomically extends only unexpired sessions. The adapters accept a structural
58
+ SQL client and do not require a particular PostgreSQL driver.
59
+
53
60
  Nothing here depends on a model. The tool shape is structurally compatible with
54
61
  [`@absolutejs/ai`](https://github.com/absolutejs/ai)'s `AIToolMap`, so an AI tool
55
62
  registry serves over MCP without conversion — but any typed tool registry works.
@@ -255,6 +262,32 @@ elicitation is safe behind a load balancer with **no sticky routing** — there
255
262
  a test for exactly that: instance A asks, the answer lands on B, the bus carries
256
263
  it back, and A's call finishes.
257
264
 
265
+ AbsoluteJS already ships both production transports. PostgreSQL is the default;
266
+ Redis is an optional at-most-once fan-out optimization:
267
+
268
+ ```ts
269
+ import { createPostgresChannelBus } from "@absolutejs/sync-bus-pg";
270
+ import type { McpElicitAnswer } from "@absolutejs/mcp";
271
+
272
+ const bus = createPostgresChannelBus<McpElicitAnswer>({
273
+ sql,
274
+ channel: "absolutejs_mcp_elicitation",
275
+ spill: "always",
276
+ });
277
+
278
+ const config = {
279
+ // ...
280
+ elicitation: {
281
+ enabled: true,
282
+ store: createPostgresMcpSessionStore({ sql }),
283
+ bus,
284
+ },
285
+ };
286
+ ```
287
+
288
+ The channel is only coordination: durable jobs and side effects belong in
289
+ `@absolutejs/queue` / `@absolutejs/execution`, not Redis pub/sub or NOTIFY.
290
+
258
291
  Consuming a server that elicits? Pass `onElicit` to `createMcpClient` — that is
259
292
  what declares the capability, and what the package uses to answer. Omit it and
260
293
  servers are told you cannot ask anyone.
package/dist/index.js CHANGED
@@ -786,14 +786,14 @@ var resourcesRead = async (config, caller, id, params) => {
786
786
  ]
787
787
  });
788
788
  };
789
- var elicitAnswer = (message, context) => {
789
+ var elicitAnswer = async (message, context) => {
790
790
  const requestId = typeof message.id === "string" ? message.id : null;
791
791
  if (!requestId || !context.sessions)
792
792
  return notificationAck();
793
793
  const result = isRecord(message.result) ? message.result : null;
794
794
  const action = result?.action;
795
795
  const answer = action === "accept" && isRecord(result?.content) ? { action: "accept", content: result.content } : action === "decline" ? { action: "decline" } : { action: "cancel" };
796
- context.sessions.resolveElicit({
796
+ await context.sessions.resolveElicit({
797
797
  requestId,
798
798
  result: answer,
799
799
  sessionId: context.sessionId ?? null
@@ -984,6 +984,7 @@ var createSessionRegistry = (options) => {
984
984
  const elicitTimeoutMs = options?.elicitTimeoutMs ?? DEFAULT_ELICIT_TIMEOUT_MS;
985
985
  const store = options?.store ?? createMemoryStore(ttlMs);
986
986
  const pending = new Map;
987
+ let unsubscribe;
987
988
  const resolveLocal = (answer) => {
988
989
  const waiting = pending.get(answer.requestId);
989
990
  if (!waiting)
@@ -993,19 +994,39 @@ var createSessionRegistry = (options) => {
993
994
  waiting.resolve(answer.result);
994
995
  return true;
995
996
  };
996
- options?.bus?.subscribe((answer) => {
997
+ const ready = options?.bus ? Promise.resolve(options.bus.subscribe((answer) => {
997
998
  resolveLocal(answer);
998
- });
999
+ })).then((stop) => {
1000
+ unsubscribe = stop;
1001
+ }) : Promise.resolve();
999
1002
  return {
1000
- create: async (canElicit) => await store.create({ canElicit }),
1003
+ ready,
1004
+ close: async () => {
1005
+ await ready;
1006
+ await unsubscribe?.();
1007
+ pending.forEach(({ resolve, timer }) => {
1008
+ clearTimeout(timer);
1009
+ resolve({ action: "cancel" });
1010
+ });
1011
+ pending.clear();
1012
+ },
1013
+ create: async (canElicit) => {
1014
+ await ready;
1015
+ return await store.create({ canElicit });
1016
+ },
1001
1017
  drop: async (id) => {
1018
+ await ready;
1002
1019
  await store.drop(id);
1003
1020
  },
1004
- get: async (id) => id ? await store.get(id) : null,
1005
- resolveElicit: (answer) => {
1021
+ get: async (id) => {
1022
+ await ready;
1023
+ return id ? await store.get(id) : null;
1024
+ },
1025
+ resolveElicit: async (answer) => {
1026
+ await ready;
1006
1027
  if (resolveLocal(answer))
1007
1028
  return true;
1008
- options?.bus?.publish(answer);
1029
+ await options?.bus?.publish(answer);
1009
1030
  return false;
1010
1031
  },
1011
1032
  startElicit: (request) => {
@@ -1129,15 +1150,121 @@ var mcpServer = (config) => {
1129
1150
  const app = config.serveRootMetadata ? base.get(ROOT_METADATA_PATH, () => metadataResponse(config)) : base;
1130
1151
  return app;
1131
1152
  };
1153
+ // src/postgres.ts
1154
+ var namespaceOf = (namespace) => {
1155
+ if (!/^[a-z_][a-z0-9_]*$/.test(namespace))
1156
+ throw new Error("MCP PostgreSQL namespace must be a simple identifier");
1157
+ return namespace;
1158
+ };
1159
+ var mcpPostgresSchemaSql = (namespace = "mcp") => {
1160
+ const ns = namespaceOf(namespace);
1161
+ return `CREATE SCHEMA IF NOT EXISTS ${ns};
1162
+ CREATE TABLE IF NOT EXISTS ${ns}.tasks (
1163
+ task_id text PRIMARY KEY,
1164
+ authorization_key text NOT NULL,
1165
+ status text NOT NULL CHECK (status IN ('working','input_required','completed','failed','cancelled')),
1166
+ created_at timestamptz NOT NULL,
1167
+ updated_at timestamptz NOT NULL,
1168
+ expires_at timestamptz,
1169
+ data jsonb NOT NULL
1170
+ );
1171
+ CREATE INDEX IF NOT EXISTS tasks_authorization_updated_idx ON ${ns}.tasks (authorization_key, updated_at DESC);
1172
+ CREATE INDEX IF NOT EXISTS tasks_expiry_idx ON ${ns}.tasks (expires_at) WHERE expires_at IS NOT NULL;
1173
+ CREATE TABLE IF NOT EXISTS ${ns}.sessions (
1174
+ session_id text PRIMARY KEY,
1175
+ can_elicit boolean NOT NULL,
1176
+ created_at timestamptz NOT NULL DEFAULT now(),
1177
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
1178
+ expires_at timestamptz NOT NULL
1179
+ );
1180
+ CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON ${ns}.sessions (expires_at);`;
1181
+ };
1182
+ var createPostgresMcpTaskStore = ({
1183
+ client,
1184
+ namespace = "mcp",
1185
+ now = () => new Date
1186
+ }) => {
1187
+ const ns = namespaceOf(namespace);
1188
+ return {
1189
+ cancel: async (taskId) => {
1190
+ const updatedAt = now().toISOString();
1191
+ await client.query(`UPDATE ${ns}.tasks SET status = 'cancelled', updated_at = $2::timestamptz, data = data || $3::jsonb WHERE task_id = $1 AND status NOT IN ('cancelled','completed','failed')`, [
1192
+ taskId,
1193
+ updatedAt,
1194
+ JSON.stringify({ lastUpdatedAt: updatedAt, status: "cancelled" })
1195
+ ]);
1196
+ },
1197
+ get: async (taskId) => (await client.query(`SELECT data FROM ${ns}.tasks WHERE task_id = $1 AND (expires_at IS NULL OR expires_at > $2::timestamptz)`, [taskId, now().toISOString()])).rows[0]?.data ?? null,
1198
+ save: async (task) => {
1199
+ const expiresAt = task.ttlMs === null ? null : new Date(new Date(task.createdAt).getTime() + task.ttlMs).toISOString();
1200
+ await client.query(`INSERT INTO ${ns}.tasks (task_id, authorization_key, status, created_at, updated_at, expires_at, data) VALUES ($1, $2, $3, $4::timestamptz, $5::timestamptz, $6::timestamptz, $7::jsonb) ON CONFLICT (task_id) DO NOTHING`, [
1201
+ task.taskId,
1202
+ task.authorizationKey,
1203
+ task.status,
1204
+ task.createdAt,
1205
+ task.lastUpdatedAt,
1206
+ expiresAt,
1207
+ JSON.stringify(task)
1208
+ ]);
1209
+ },
1210
+ update: async (taskId, update) => {
1211
+ const updatedAt = now().toISOString();
1212
+ const data = { ...update, lastUpdatedAt: updatedAt };
1213
+ const result = await client.query(`UPDATE ${ns}.tasks SET status = COALESCE($2::text, status), updated_at = $3::timestamptz, data = data || $4::jsonb WHERE task_id = $1 AND status NOT IN ('cancelled','completed','failed') RETURNING data`, [taskId, update.status ?? null, updatedAt, JSON.stringify(data)]);
1214
+ if (result.rows[0] !== undefined)
1215
+ return result.rows[0].data;
1216
+ return (await client.query(`SELECT data FROM ${ns}.tasks WHERE task_id = $1`, [taskId])).rows[0]?.data ?? null;
1217
+ }
1218
+ };
1219
+ };
1220
+ var createPostgresMcpSessionStore = ({
1221
+ client,
1222
+ namespace = "mcp",
1223
+ now = () => new Date,
1224
+ ttlMs = 3600000
1225
+ }) => {
1226
+ const ns = namespaceOf(namespace);
1227
+ return {
1228
+ create: async ({ canElicit }) => {
1229
+ const id = crypto.randomUUID();
1230
+ const current = now();
1231
+ await client.query(`INSERT INTO ${ns}.sessions (session_id, can_elicit, created_at, last_seen_at, expires_at) VALUES ($1, $2, $3::timestamptz, $3::timestamptz, $4::timestamptz)`, [
1232
+ id,
1233
+ canElicit,
1234
+ current.toISOString(),
1235
+ new Date(current.getTime() + ttlMs).toISOString()
1236
+ ]);
1237
+ return id;
1238
+ },
1239
+ drop: async (id) => {
1240
+ await client.query(`DELETE FROM ${ns}.sessions WHERE session_id = $1`, [
1241
+ id
1242
+ ]);
1243
+ },
1244
+ get: async (id) => {
1245
+ const current = now();
1246
+ const result = await client.query(`UPDATE ${ns}.sessions SET last_seen_at = $2::timestamptz, expires_at = $3::timestamptz WHERE session_id = $1 AND expires_at > $2::timestamptz RETURNING can_elicit`, [
1247
+ id,
1248
+ current.toISOString(),
1249
+ new Date(current.getTime() + ttlMs).toISOString()
1250
+ ]);
1251
+ const row = result.rows[0];
1252
+ return row === undefined ? null : { canElicit: row.can_elicit };
1253
+ }
1254
+ };
1255
+ };
1132
1256
  export {
1133
1257
  verifyBearer,
1134
1258
  publicMcpTask,
1135
1259
  protectedResourceMetadata,
1136
1260
  metadataPathFor,
1137
1261
  mcpServer,
1262
+ mcpPostgresSchemaSql,
1138
1263
  feedbackTools,
1139
1264
  dispatchMcp,
1140
1265
  createSessionRegistry,
1266
+ createPostgresMcpTaskStore,
1267
+ createPostgresMcpSessionStore,
1141
1268
  createMemoryMcpTaskStore,
1142
1269
  createMcpHandler,
1143
1270
  createMcpClient,
@@ -39,4 +39,5 @@ export { metadataPathFor, protectedResourceMetadata, type ProtectedResourceMetad
39
39
  export { mcpServer } from "./server";
40
40
  export { createSessionRegistry, type SessionRegistry } from "./sessions";
41
41
  export { createMemoryMcpTaskStore, publicMcpTask } from "./tasks";
42
+ export { createPostgresMcpSessionStore, createPostgresMcpTaskStore, mcpPostgresSchemaSql, type McpSqlClient, type McpSqlResult, } from "./postgres";
42
43
  export type { McpAgencyOptions, McpAudioContent, McpElicitAnswer, McpElicitationRequest, McpElicitBus, McpElicitResult, McpSessionStore, McpAuthResult, McpCallGate, McpCallMeta, McpContent, McpImageContent, McpPromptArgument, McpPromptDefinition, McpPrompts, McpResource, McpResourceLink, McpResources, McpServerConfig, McpServerInfo, McpTextContent, McpTask, McpTaskStatus, McpTaskStore, McpTasksOptions, McpTool, McpToolAnnotations, McpToolCallContext, McpToolContext, McpToolRegistry, McpToolResult, McpToolReturn, } from "./types";
@@ -0,0 +1,20 @@
1
+ import type { McpSessionStore, McpTaskStore } from "./types";
2
+ export type McpSqlResult<Row> = {
3
+ rowCount: number;
4
+ rows: ReadonlyArray<Row>;
5
+ };
6
+ export type McpSqlClient = {
7
+ query: <Row = Record<string, unknown>>(sql: string, parameters?: ReadonlyArray<unknown>) => Promise<McpSqlResult<Row>>;
8
+ };
9
+ export declare const mcpPostgresSchemaSql: (namespace?: string) => string;
10
+ export declare const createPostgresMcpTaskStore: ({ client, namespace, now, }: {
11
+ client: McpSqlClient;
12
+ namespace?: string;
13
+ now?: () => Date;
14
+ }) => McpTaskStore;
15
+ export declare const createPostgresMcpSessionStore: ({ client, namespace, now, ttlMs, }: {
16
+ client: McpSqlClient;
17
+ namespace?: string;
18
+ now?: () => Date;
19
+ ttlMs?: number;
20
+ }) => McpSessionStore;
@@ -6,6 +6,8 @@ export declare const createSessionRegistry: (options?: {
6
6
  store?: McpSessionStore;
7
7
  ttlMs?: number;
8
8
  }) => {
9
+ ready: Promise<void>;
10
+ close: () => Promise<void>;
9
11
  create: (canElicit: boolean) => Promise<string>;
10
12
  drop: (id: string) => Promise<void>;
11
13
  get: (id: string | null) => Promise<{
@@ -14,7 +16,7 @@ export declare const createSessionRegistry: (options?: {
14
16
  /** The client answered. If the call that asked is running HERE, resolve it.
15
17
  * If not, put the answer on the bus so the instance that is waiting can —
16
18
  * the answer must find the promise, and the promise cannot move. */
17
- resolveElicit: (answer: McpElicitAnswer) => boolean;
19
+ resolveElicit: (answer: McpElicitAnswer) => Promise<boolean>;
18
20
  /** Register an outbound question. Returns the id to send it under and the
19
21
  * promise that settles when the user answers — or when they never do. */
20
22
  startElicit: (request: McpElicitationRequest) => {
@@ -93,8 +93,8 @@ export type McpSessionStore = {
93
93
  * routing. Omit it and you must run a single instance (or pin sessions). */
94
94
  export type McpElicitBus = {
95
95
  /** An answer nobody here was waiting for — someone else might be. */
96
- publish: (answer: McpElicitAnswer) => void;
97
- subscribe: (handler: (answer: McpElicitAnswer) => void) => void;
96
+ publish: (answer: McpElicitAnswer) => void | Promise<void>;
97
+ subscribe: (handler: (answer: McpElicitAnswer) => void) => void | (() => void | Promise<void>) | Promise<void | (() => void | Promise<void>)>;
98
98
  };
99
99
  /** Passed to a tool handler as its second argument. Ignore it and nothing
100
100
  * changes — every existing handler keeps working. */
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  "elysia": ">=1.1.0"
12
12
  },
13
13
  "dependencies": {
14
- "@absolutejs/agency": "^0.2.0",
14
+ "@absolutejs/agency": "^0.3.0",
15
15
  "@absolutejs/manifest": "^0.2.0",
16
16
  "@sinclair/typebox": "^0.34.0"
17
17
  },
@@ -58,5 +58,5 @@
58
58
  "typecheck": "tsc --noEmit --project tsconfig.json"
59
59
  },
60
60
  "types": "./dist/src/index.d.ts",
61
- "version": "0.5.1"
61
+ "version": "0.7.0"
62
62
  }