@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 +33 -0
- package/dist/index.js +135 -8
- package/dist/src/index.d.ts +1 -0
- package/dist/src/postgres.d.ts +20 -0
- package/dist/src/sessions.d.ts +3 -1
- package/dist/src/types.d.ts +2 -2
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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) =>
|
|
1005
|
-
|
|
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,
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/sessions.d.ts
CHANGED
|
@@ -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) => {
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
61
|
+
"version": "0.7.0"
|
|
62
62
|
}
|