@aventara/mcp 0.0.0-stage → 1.0.0-rc.10
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/CHANGELOG.md +184 -0
- package/LICENSE +91 -0
- package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
- package/README.md +166 -2
- package/dist/av-mcp.d.ts +5 -0
- package/dist/av-mcp.js +1 -0
- package/dist/endpoint-access.policy.d.ts +35 -0
- package/dist/endpoint-access.policy.js +110 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1 -0
- package/dist/json-text.writer.d.ts +7 -0
- package/dist/json-text.writer.js +44 -0
- package/dist/mcp.binder.d.ts +74 -0
- package/dist/mcp.binder.js +295 -0
- package/dist/tool-call.translator.d.ts +33 -0
- package/dist/tool-call.translator.js +172 -0
- package/dist/tool-catalog.compiler.d.ts +49 -0
- package/dist/tool-catalog.compiler.js +226 -0
- package/dist/tool-result.encoder.d.ts +28 -0
- package/dist/tool-result.encoder.js +29 -0
- package/package.json +38 -3
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function jsonTextOf(value) {
|
|
2
|
+
const output = [];
|
|
3
|
+
const stack = [
|
|
4
|
+
{ value },
|
|
5
|
+
];
|
|
6
|
+
while (stack.length > 0) {
|
|
7
|
+
const task = stack.pop();
|
|
8
|
+
if ("text" in task) {
|
|
9
|
+
output.push(task.text);
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const current = task.value;
|
|
13
|
+
if (typeof current !== "object" || current === null) {
|
|
14
|
+
output.push(JSON.stringify(current) ?? "null");
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(current)) {
|
|
18
|
+
output.push("[");
|
|
19
|
+
stack.push({ text: "]" });
|
|
20
|
+
for (let index = current.length - 1; index >= 0; index -= 1) {
|
|
21
|
+
stack.push({ value: current[index] });
|
|
22
|
+
if (index > 0) {
|
|
23
|
+
stack.push({ text: "," });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const record = current;
|
|
29
|
+
const keys = Object.keys(record).filter((key) => hasJsonForm(record[key]));
|
|
30
|
+
output.push("{");
|
|
31
|
+
stack.push({ text: "}" });
|
|
32
|
+
for (let index = keys.length - 1; index >= 0; index -= 1) {
|
|
33
|
+
const key = keys[index];
|
|
34
|
+
stack.push({ value: record[key] });
|
|
35
|
+
stack.push({ text: `${index > 0 ? "," : ""}${JSON.stringify(key)}:` });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return output.join("");
|
|
39
|
+
}
|
|
40
|
+
function hasJsonForm(value) {
|
|
41
|
+
return (value !== undefined &&
|
|
42
|
+
typeof value !== "function" &&
|
|
43
|
+
typeof value !== "symbol");
|
|
44
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { AvFramework, AvState } from "@aventara/core";
|
|
2
|
+
import { type AvProtocolResponse } from "@aventara/core/protocol";
|
|
3
|
+
/**
|
|
4
|
+
* One HTTP request to the MCP endpoint or its metadata, as a host hands it to
|
|
5
|
+
* the binding.
|
|
6
|
+
*/
|
|
7
|
+
export type AvMcpRequest = {
|
|
8
|
+
/** The HTTP method as received. */
|
|
9
|
+
readonly method: string;
|
|
10
|
+
/**
|
|
11
|
+
* The request's absolute URL as the host sees it (scheme, host, path,
|
|
12
|
+
* query), its proxy settings applied. The OAuth metadata names the endpoint
|
|
13
|
+
* by its origin.
|
|
14
|
+
*/
|
|
15
|
+
readonly url: string;
|
|
16
|
+
/** The request headers as received; a repeated header as its list of values. */
|
|
17
|
+
readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
18
|
+
/** The body as received: at most `maxRequestBytes + 1` bytes are needed. */
|
|
19
|
+
readonly body: string | Uint8Array;
|
|
20
|
+
/**
|
|
21
|
+
* The socket peer's address. An endpoint without `authorization` answers
|
|
22
|
+
* loopback callers only.
|
|
23
|
+
*/
|
|
24
|
+
readonly remoteAddress?: string | undefined;
|
|
25
|
+
};
|
|
26
|
+
/** The routes a host mounts: the endpoint, then its OAuth metadata when it has `authorization`. */
|
|
27
|
+
type McpRoute = {
|
|
28
|
+
/** `endpoint`: answered by `handleEndpoint`; `metadata`: by `encodeMetadata`. */
|
|
29
|
+
readonly kind: "endpoint" | "metadata";
|
|
30
|
+
/** The absolute path. */
|
|
31
|
+
readonly path: string;
|
|
32
|
+
/** The methods to mount on it; the binding answers each, a refusal included. */
|
|
33
|
+
readonly methods: readonly ("GET" | "POST" | "DELETE")[];
|
|
34
|
+
};
|
|
35
|
+
/** The MCP server bound to one Framework, as `AvMcp.bind` returns it. */
|
|
36
|
+
export type AvMcpBinding = {
|
|
37
|
+
/**
|
|
38
|
+
* The routes to mount: the endpoint at `framework.mcp.path` (`/` at the
|
|
39
|
+
* root) for `POST`, `GET` and `DELETE` (the Streamable HTTP methods; this
|
|
40
|
+
* stateless endpoint answers the last two `405`), and, when the
|
|
41
|
+
* configuration has `mcp.authorization`, the protected-resource metadata at
|
|
42
|
+
* `/.well-known/oauth-protected-resource` followed by the endpoint's path,
|
|
43
|
+
* for `GET`.
|
|
44
|
+
*/
|
|
45
|
+
readonly surface: () => readonly McpRoute[];
|
|
46
|
+
/** The largest request body the endpoint reads: the Agent Contract's `maxRequestBytes`. */
|
|
47
|
+
readonly maxRequestBytes: number;
|
|
48
|
+
/**
|
|
49
|
+
* Answers one request to the endpoint: the caller checked (a bearer token
|
|
50
|
+
* through `verify`, or, with no `authorization`, a caller on this machine),
|
|
51
|
+
* then the MCP message answered as JSON, statelessly. A tool call runs as an
|
|
52
|
+
* agent-origin protocol request. Never rejects: a failure of its own (the MCP
|
|
53
|
+
* SDK's) is answered `500` with no detail and reported to the Framework's
|
|
54
|
+
* diagnostics sink as an `AvMcpDiagnostic`; a `verify` that throws is
|
|
55
|
+
* answered `401` and reported the same way.
|
|
56
|
+
*/
|
|
57
|
+
readonly handleEndpoint: (request: AvMcpRequest, options?: {
|
|
58
|
+
/**
|
|
59
|
+
* Seeds each tool call's `ctx.$state`, as for a protocol request. With
|
|
60
|
+
* `authorization`, the state also carries `mcp: { claims }`: what
|
|
61
|
+
* `verify` resolved the token to.
|
|
62
|
+
*/
|
|
63
|
+
readonly state?: () => AvState | Promise<AvState>;
|
|
64
|
+
}) => Promise<AvProtocolResponse>;
|
|
65
|
+
/** Answers the protected-resource metadata route (RFC 9728). */
|
|
66
|
+
readonly encodeMetadata: (request: AvMcpRequest) => AvProtocolResponse;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* `AvMcp.bind(framework)`: the MCP server for a Framework whose configuration
|
|
70
|
+
* has `mcp`, bound once at startup. Its tools and its contract resource are read
|
|
71
|
+
* from the Agent Contract now.
|
|
72
|
+
*/
|
|
73
|
+
export declare function bind(framework: AvFramework): AvMcpBinding;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { accessProviderOf, mcpFailureReporterOf, projectJsonValue, } from "@aventara/core/internal";
|
|
2
|
+
import { AvProtocol, } from "@aventara/core/protocol";
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
5
|
+
import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
|
7
|
+
import { authorizedAccess, jsonAnswer, loopbackAccess, metadataAnswer, metadataPathOf, } from "./endpoint-access.policy.js";
|
|
8
|
+
import { translateOperationCall, translateTransactionCall, } from "./tool-call.translator.js";
|
|
9
|
+
import { CONTRACT_RESOURCE_URI, compileToolCatalog, ME_TOOL, OPERATION_FAMILIES, TRANSACTION_TOOL, } from "./tool-catalog.compiler.js";
|
|
10
|
+
import { internalFailureResult, refusedCallResult, toolResultOf, } from "./tool-result.encoder.js";
|
|
11
|
+
const SERVER_INSTRUCTIONS = `This server reads and writes an application's data through Aventara. Read the resource ${CONTRACT_RESOURCE_URI} first: it describes every resource, its fields and the operations it offers. Then call a tool with { resource, variant, args }. A failed call answers isError with the outcome code and the issues to fix.`;
|
|
12
|
+
export function bind(framework) {
|
|
13
|
+
const endpoint = framework.mcp;
|
|
14
|
+
if (endpoint === undefined) {
|
|
15
|
+
throw new Error("AvMcp.bind: this Framework's configuration has no `mcp`, so it serves no MCP endpoint. Add `mcp: '/mcp'` (or `{ path, authorization }`) to the configuration.");
|
|
16
|
+
}
|
|
17
|
+
const agent = framework.contracts.agent;
|
|
18
|
+
const protocol = AvProtocol.bind(framework, { origin: "agent" });
|
|
19
|
+
const me = accessProviderOf(framework)?.client?.verbs.me;
|
|
20
|
+
const bound = {
|
|
21
|
+
endpoint,
|
|
22
|
+
protocol,
|
|
23
|
+
identity: { version: agent.protocol.version, hash: agent.protocol.hash },
|
|
24
|
+
tools: compileToolCatalog({
|
|
25
|
+
surface: protocol.surface(),
|
|
26
|
+
maxTransactionOperations: agent.limits.maxTransactionOperations,
|
|
27
|
+
me: me !== undefined,
|
|
28
|
+
}),
|
|
29
|
+
contractDocument: protocol.encodeContract({
|
|
30
|
+
method: "GET",
|
|
31
|
+
path: "/_contract",
|
|
32
|
+
headers: {},
|
|
33
|
+
body: { kind: "raw", content: "" },
|
|
34
|
+
}).body,
|
|
35
|
+
maxRequestBytes: agent.limits.maxRequestBytes,
|
|
36
|
+
reportFailure: mcpFailureReporterOf(framework),
|
|
37
|
+
schemaValidator: new AjvJsonSchemaValidator(),
|
|
38
|
+
me,
|
|
39
|
+
};
|
|
40
|
+
const routes = Object.freeze([
|
|
41
|
+
Object.freeze({
|
|
42
|
+
kind: "endpoint",
|
|
43
|
+
path: endpoint.path === "" ? "/" : endpoint.path,
|
|
44
|
+
methods: Object.freeze(["POST", "GET", "DELETE"]),
|
|
45
|
+
}),
|
|
46
|
+
...(endpoint.authorization === undefined
|
|
47
|
+
? []
|
|
48
|
+
: [
|
|
49
|
+
Object.freeze({
|
|
50
|
+
kind: "metadata",
|
|
51
|
+
path: metadataPathOf(endpoint.path),
|
|
52
|
+
methods: Object.freeze(["GET"]),
|
|
53
|
+
}),
|
|
54
|
+
]),
|
|
55
|
+
]);
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
surface: () => routes,
|
|
58
|
+
maxRequestBytes: bound.maxRequestBytes,
|
|
59
|
+
handleEndpoint: (request, options) => answerEndpoint(bound, request, options?.state),
|
|
60
|
+
encodeMetadata: (request) => answerMetadata(bound.endpoint, request),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function headerReader(headers) {
|
|
64
|
+
const lowered = new Map();
|
|
65
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
66
|
+
const values = typeof value === "string" ? [value] : (value ?? []);
|
|
67
|
+
if (values.length > 0) {
|
|
68
|
+
const key = name.toLowerCase();
|
|
69
|
+
const before = lowered.get(key);
|
|
70
|
+
lowered.set(key, before === undefined
|
|
71
|
+
? values.join(", ")
|
|
72
|
+
: `${before}, ${values.join(", ")}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return (name) => lowered.get(name.toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
function urlOf(request) {
|
|
78
|
+
try {
|
|
79
|
+
return new URL(request.url);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const BAD_URL = () => jsonAnswer(400, {
|
|
86
|
+
error: "invalid_request",
|
|
87
|
+
error_description: "The request URL is not absolute.",
|
|
88
|
+
});
|
|
89
|
+
function answerMetadata(endpoint, request) {
|
|
90
|
+
const url = urlOf(request);
|
|
91
|
+
if (url === undefined) {
|
|
92
|
+
return BAD_URL();
|
|
93
|
+
}
|
|
94
|
+
if (endpoint.authorization === undefined) {
|
|
95
|
+
return jsonAnswer(404, {
|
|
96
|
+
error: "not_found",
|
|
97
|
+
error_description: "This MCP endpoint has no authorization.",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return metadataAnswer(request.method, url.origin, endpoint.path, endpoint.authorization);
|
|
101
|
+
}
|
|
102
|
+
const rpcRefusal = (status, message, headers = {}) => jsonAnswer(status, { jsonrpc: "2.0", error: { code: -32000, message }, id: null }, headers);
|
|
103
|
+
async function answerEndpoint(bound, request, state) {
|
|
104
|
+
try {
|
|
105
|
+
const url = urlOf(request);
|
|
106
|
+
if (url === undefined) {
|
|
107
|
+
return BAD_URL();
|
|
108
|
+
}
|
|
109
|
+
const header = headerReader(request.headers);
|
|
110
|
+
const { authorization } = bound.endpoint;
|
|
111
|
+
const access = authorization === undefined
|
|
112
|
+
? loopbackAccess(request.remoteAddress, header)
|
|
113
|
+
: await authorizedAccess(header("authorization"), url.origin, bound.endpoint.path, authorization, bound.reportFailure);
|
|
114
|
+
if (!access.ok) {
|
|
115
|
+
return access.response;
|
|
116
|
+
}
|
|
117
|
+
if (request.method !== "POST") {
|
|
118
|
+
return rpcRefusal(405, "Method not allowed: this endpoint is stateless; use POST.", {
|
|
119
|
+
Allow: "POST",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const coding = header("content-encoding")?.trim().toLowerCase();
|
|
123
|
+
if (coding !== undefined && coding !== "" && coding !== "identity") {
|
|
124
|
+
return rpcRefusal(415, "Unsupported Content-Encoding.");
|
|
125
|
+
}
|
|
126
|
+
return await answerMessage(bound, request, url, seedOf(state, access.caller));
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
bound.reportFailure(error);
|
|
130
|
+
return rpcRefusal(500, "Internal error.");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function seedOf(state, caller) {
|
|
134
|
+
if (state === undefined && caller === undefined) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
return async () => {
|
|
138
|
+
const seeded = state === undefined ? {} : await state();
|
|
139
|
+
return caller === undefined
|
|
140
|
+
? seeded
|
|
141
|
+
: { ...seeded, mcp: { claims: caller.claims } };
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const DROPPED_HEADERS = new Set([
|
|
145
|
+
"host",
|
|
146
|
+
"connection",
|
|
147
|
+
"keep-alive",
|
|
148
|
+
"transfer-encoding",
|
|
149
|
+
"upgrade",
|
|
150
|
+
"content-length",
|
|
151
|
+
"content-encoding",
|
|
152
|
+
]);
|
|
153
|
+
function webRequestOf(request, url) {
|
|
154
|
+
const headers = new Headers();
|
|
155
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
156
|
+
if (DROPPED_HEADERS.has(name.toLowerCase()) || value === undefined) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
for (const item of typeof value === "string" ? [value] : value) {
|
|
160
|
+
headers.append(name, item);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return new Request(url, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers,
|
|
166
|
+
body: typeof request.body === "string"
|
|
167
|
+
? request.body
|
|
168
|
+
: new Uint8Array(request.body),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
async function answerMessage(bound, request, url, state) {
|
|
172
|
+
const server = serverFor(bound, request.headers, state);
|
|
173
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
174
|
+
enableJsonResponse: true,
|
|
175
|
+
maxRequestBodySize: bound.maxRequestBytes,
|
|
176
|
+
});
|
|
177
|
+
try {
|
|
178
|
+
await server.connect(transport);
|
|
179
|
+
const response = await transport.handleRequest(webRequestOf(request, url));
|
|
180
|
+
const headers = {};
|
|
181
|
+
response.headers.forEach((value, name) => {
|
|
182
|
+
headers[name] = value;
|
|
183
|
+
});
|
|
184
|
+
return { status: response.status, headers, body: await response.text() };
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
await server.close();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function serverFor(bound, headers, state) {
|
|
191
|
+
const server = new Server({ name: "aventara", title: "Aventara", version: bound.identity.hash }, {
|
|
192
|
+
capabilities: { tools: {}, resources: {} },
|
|
193
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
194
|
+
jsonSchemaValidator: bound.schemaValidator,
|
|
195
|
+
});
|
|
196
|
+
const options = state === undefined ? {} : { state };
|
|
197
|
+
server.setRequestHandler(ListToolsRequestSchema, guarded(bound, () => ({ tools: bound.tools })));
|
|
198
|
+
server.setRequestHandler(CallToolRequestSchema, async (call) => {
|
|
199
|
+
const name = call.params.name;
|
|
200
|
+
if (!bound.tools.some((tool) => tool.name === name)) {
|
|
201
|
+
throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return await callTool(bound, name, call.params.arguments, headers, options);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
return internalFailureResult(bound.reportFailure(error));
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
server.setRequestHandler(ListResourcesRequestSchema, guarded(bound, () => ({
|
|
211
|
+
resources: [
|
|
212
|
+
{
|
|
213
|
+
uri: CONTRACT_RESOURCE_URI,
|
|
214
|
+
name: "contract",
|
|
215
|
+
title: "Agent Contract",
|
|
216
|
+
description: "Every resource the tools reach: its fields and their types, what can be filtered, selected, created and updated, the operations it offers, and the request limits.",
|
|
217
|
+
mimeType: "application/json",
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
})));
|
|
221
|
+
server.setRequestHandler(ReadResourceRequestSchema, guarded(bound, (read) => {
|
|
222
|
+
if (read.params.uri !== CONTRACT_RESOURCE_URI) {
|
|
223
|
+
throw new McpError(-32002, `Resource not found: ${read.params.uri}`);
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
contents: [
|
|
227
|
+
{
|
|
228
|
+
uri: CONTRACT_RESOURCE_URI,
|
|
229
|
+
mimeType: "application/json",
|
|
230
|
+
text: bound.contractDocument,
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
};
|
|
234
|
+
}));
|
|
235
|
+
return server;
|
|
236
|
+
}
|
|
237
|
+
function guarded(bound, handler) {
|
|
238
|
+
return async (...args) => {
|
|
239
|
+
try {
|
|
240
|
+
return await handler(...args);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (error instanceof McpError) {
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
throw new McpError(ErrorCode.InternalError, bound.reportFailure(error).cause.message);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
async function callTool(bound, name, input, headers, options) {
|
|
251
|
+
if (name === ME_TOOL) {
|
|
252
|
+
return (await answerMe(bound, input, headers));
|
|
253
|
+
}
|
|
254
|
+
if (name === TRANSACTION_TOOL) {
|
|
255
|
+
const translated = await translateTransactionCall(input, headers, bound.identity);
|
|
256
|
+
return (translated.ok
|
|
257
|
+
? toolResultOf(await bound.protocol.handleTransaction(translated.request, options))
|
|
258
|
+
: refusedCallResult(translated.message));
|
|
259
|
+
}
|
|
260
|
+
const family = OPERATION_FAMILIES.find((candidate) => candidate === name);
|
|
261
|
+
const translated = translateOperationCall(family, input, headers, bound.identity);
|
|
262
|
+
return (translated.ok
|
|
263
|
+
? toolResultOf(await bound.protocol.handleOperation(translated.request, options))
|
|
264
|
+
: refusedCallResult(translated.message));
|
|
265
|
+
}
|
|
266
|
+
async function answerMe(bound, input, headers) {
|
|
267
|
+
if (input !== undefined &&
|
|
268
|
+
(typeof input !== "object" ||
|
|
269
|
+
input === null ||
|
|
270
|
+
Array.isArray(input) ||
|
|
271
|
+
Object.keys(input).length > 0)) {
|
|
272
|
+
return refusedCallResult("me takes no arguments: call it with {}.");
|
|
273
|
+
}
|
|
274
|
+
const me = bound.me;
|
|
275
|
+
if (me === undefined) {
|
|
276
|
+
throw new Error("The me tool was called on a binding that offers none.");
|
|
277
|
+
}
|
|
278
|
+
const identity = await me.call({
|
|
279
|
+
args: undefined,
|
|
280
|
+
headers: webHeadersOf(headers),
|
|
281
|
+
});
|
|
282
|
+
const data = identity === null || identity === undefined
|
|
283
|
+
? null
|
|
284
|
+
: projectJsonValue(identity, me.result);
|
|
285
|
+
return toolResultOf(jsonAnswer(200, { code: "A1000", data, cause: null }));
|
|
286
|
+
}
|
|
287
|
+
function webHeadersOf(headers) {
|
|
288
|
+
const web = new Headers();
|
|
289
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
290
|
+
for (const item of typeof value === "string" ? [value] : (value ?? [])) {
|
|
291
|
+
web.append(name, item);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return web;
|
|
295
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type AvProtocolRequest } from "@aventara/core/protocol";
|
|
2
|
+
import { type OperationFamily } from "./tool-catalog.compiler.js";
|
|
3
|
+
/** The Agent Contract's identity, sent as the protocol's identity headers. */
|
|
4
|
+
export type AgentIdentity = {
|
|
5
|
+
readonly version: number;
|
|
6
|
+
readonly hash: string;
|
|
7
|
+
};
|
|
8
|
+
/** A tool call as the protocol request that runs it, or why it cannot be one. */
|
|
9
|
+
type TranslatedCall = {
|
|
10
|
+
readonly ok: true;
|
|
11
|
+
readonly kind: "operation" | "transaction";
|
|
12
|
+
readonly request: AvProtocolRequest;
|
|
13
|
+
} | {
|
|
14
|
+
readonly ok: false;
|
|
15
|
+
readonly message: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* A family tool's call: `{ resource, variant, args? }` as the operation route
|
|
19
|
+
* `/_resources/<resource>/<family>/<variant>` with `args` (or `{}`) as its body.
|
|
20
|
+
* An unknown Resource or variant is the protocol's to refuse (`A2001`, `A2002`);
|
|
21
|
+
* only a call that names none as a string is refused here.
|
|
22
|
+
*/
|
|
23
|
+
export declare function translateOperationCall(family: OperationFamily, input: unknown, received: Readonly<Record<string, string | readonly string[] | undefined>>, identity: AgentIdentity): TranslatedCall;
|
|
24
|
+
/**
|
|
25
|
+
* The transaction tool's call: `{ steps: [{ resource, family, variant, args? }] }`
|
|
26
|
+
* as the `/_transactions` plan `{ operations: [...] }`, each step carrying the
|
|
27
|
+
* fingerprint the protocol requires and each `{ $ref: { operation, path } }`
|
|
28
|
+
* the fingerprint of the step it names — computed here, since an agent cannot,
|
|
29
|
+
* by the protocol's own algorithm. A reference that names no earlier step is
|
|
30
|
+
* left as written, for the protocol to refuse.
|
|
31
|
+
*/
|
|
32
|
+
export declare function translateTransactionCall(input: unknown, received: Readonly<Record<string, string | readonly string[] | undefined>>, identity: AgentIdentity): Promise<TranslatedCall>;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { computeOperationFingerprint } from "@aventara/core/internal";
|
|
2
|
+
import { AvProtocol } from "@aventara/core/protocol";
|
|
3
|
+
import { jsonTextOf } from "./json-text.writer.js";
|
|
4
|
+
import { CONTRACT_RESOURCE_URI, OPERATION_FAMILIES, TRANSACTION_TOOL, } from "./tool-catalog.compiler.js";
|
|
5
|
+
const REPLACED_HEADERS = new Set([
|
|
6
|
+
"content-type",
|
|
7
|
+
"content-length",
|
|
8
|
+
"content-encoding",
|
|
9
|
+
"transfer-encoding",
|
|
10
|
+
AvProtocol.headers.protocolVersion.toLowerCase(),
|
|
11
|
+
AvProtocol.headers.contractHash.toLowerCase(),
|
|
12
|
+
]);
|
|
13
|
+
function headersOf(received, identity) {
|
|
14
|
+
const headers = {};
|
|
15
|
+
for (const [name, value] of Object.entries(received)) {
|
|
16
|
+
if (!REPLACED_HEADERS.has(name.toLowerCase())) {
|
|
17
|
+
headers[name] = value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
headers["content-type"] = "application/json";
|
|
21
|
+
headers[AvProtocol.headers.protocolVersion] = String(identity.version);
|
|
22
|
+
headers[AvProtocol.headers.contractHash] = identity.hash;
|
|
23
|
+
return headers;
|
|
24
|
+
}
|
|
25
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
|
+
const isFamily = (value) => OPERATION_FAMILIES.includes(value);
|
|
27
|
+
function protocolRequest(path, body, headers) {
|
|
28
|
+
return {
|
|
29
|
+
method: "POST",
|
|
30
|
+
path,
|
|
31
|
+
headers,
|
|
32
|
+
body: { kind: "raw", content: jsonTextOf(body) },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function routeSegmentOf(name) {
|
|
36
|
+
return encodeURIComponent(name.replace(/\p{Surrogate}/gu, "\uFFFD"));
|
|
37
|
+
}
|
|
38
|
+
export function translateOperationCall(family, input, received, identity) {
|
|
39
|
+
if (!isRecord(input)) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
message: `${family}: the arguments must be an object.`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const { resource, variant, args } = input;
|
|
46
|
+
if (typeof resource !== "string" || typeof variant !== "string") {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
message: `${family}: \`resource\` and \`variant\` must be strings (read ${CONTRACT_RESOURCE_URI}).`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const path = `/_resources/${[resource, family, variant].map(routeSegmentOf).join("/")}`;
|
|
53
|
+
return {
|
|
54
|
+
ok: true,
|
|
55
|
+
kind: "operation",
|
|
56
|
+
request: protocolRequest(path, args === undefined ? {} : args, headersOf(received, identity)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export async function translateTransactionCall(input, received, identity) {
|
|
60
|
+
if (!isRecord(input) || !Array.isArray(input.steps)) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
message: `${TRANSACTION_TOOL}: the arguments must be { steps: [{ resource, family, variant, args }] }.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const fingerprints = [];
|
|
67
|
+
const operations = [];
|
|
68
|
+
for (const [index, step] of input.steps.entries()) {
|
|
69
|
+
if (!isRecord(step) ||
|
|
70
|
+
typeof step.resource !== "string" ||
|
|
71
|
+
!isFamily(step.family) ||
|
|
72
|
+
typeof step.variant !== "string") {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
message: `${TRANSACTION_TOOL}: step ${index} must be { resource, family, variant, args } with family one of ${OPERATION_FAMILIES.join(", ")}.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const args = referenced(step.args === undefined ? {} : step.args, fingerprints);
|
|
79
|
+
const node = {
|
|
80
|
+
resource: step.resource,
|
|
81
|
+
family: step.family,
|
|
82
|
+
variant: step.variant,
|
|
83
|
+
args,
|
|
84
|
+
};
|
|
85
|
+
const fingerprint = await fingerprintOf(node);
|
|
86
|
+
fingerprints.push(fingerprint);
|
|
87
|
+
operations.push({ ...node, fingerprint });
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
kind: "transaction",
|
|
92
|
+
request: protocolRequest("/_transactions", { operations }, headersOf(received, identity)),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async function fingerprintOf(node) {
|
|
96
|
+
try {
|
|
97
|
+
return await computeOperationFingerprint(node);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function referenced(value, fingerprints) {
|
|
104
|
+
const root = {};
|
|
105
|
+
const stack = [
|
|
106
|
+
{
|
|
107
|
+
source: value,
|
|
108
|
+
assign: (copy) => {
|
|
109
|
+
root.value = copy;
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
while (stack.length > 0) {
|
|
114
|
+
const { source, assign } = stack.pop();
|
|
115
|
+
if (Array.isArray(source)) {
|
|
116
|
+
const copy = new Array(source.length);
|
|
117
|
+
assign(copy);
|
|
118
|
+
for (let index = source.length - 1; index >= 0; index -= 1) {
|
|
119
|
+
stack.push({
|
|
120
|
+
source: source[index],
|
|
121
|
+
assign: (item) => {
|
|
122
|
+
copy[index] = item;
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!isRecord(source)) {
|
|
129
|
+
assign(source);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const reference = withFingerprint(source, fingerprints);
|
|
133
|
+
if (reference !== undefined) {
|
|
134
|
+
assign(reference);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const copy = {};
|
|
138
|
+
assign(copy);
|
|
139
|
+
const keys = Object.keys(source);
|
|
140
|
+
for (const key of keys) {
|
|
141
|
+
Object.defineProperty(copy, key, {
|
|
142
|
+
value: undefined,
|
|
143
|
+
writable: true,
|
|
144
|
+
enumerable: true,
|
|
145
|
+
configurable: true,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
for (let index = keys.length - 1; index >= 0; index -= 1) {
|
|
149
|
+
const key = keys[index];
|
|
150
|
+
stack.push({
|
|
151
|
+
source: source[key],
|
|
152
|
+
assign: (member) => {
|
|
153
|
+
copy[key] = member;
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return root.value;
|
|
159
|
+
}
|
|
160
|
+
function withFingerprint(value, fingerprints) {
|
|
161
|
+
const reference = value.$ref;
|
|
162
|
+
if (Object.keys(value).length !== 1 || !isRecord(reference)) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
const operation = reference.operation;
|
|
166
|
+
const fingerprint = typeof operation === "number" && Number.isInteger(operation)
|
|
167
|
+
? fingerprints[operation]
|
|
168
|
+
: undefined;
|
|
169
|
+
return fingerprint === undefined || fingerprint === null
|
|
170
|
+
? value
|
|
171
|
+
: { $ref: { ...reference, fingerprint } };
|
|
172
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { AvProtocolBinding } from "@aventara/core/protocol";
|
|
2
|
+
/** The operation families, in the order the protocol defines them. */
|
|
3
|
+
export declare const OPERATION_FAMILIES: readonly ["find", "create", "update", "delete", "upsert"];
|
|
4
|
+
/** One operation family: the name of its MCP tool. */
|
|
5
|
+
export type OperationFamily = (typeof OPERATION_FAMILIES)[number];
|
|
6
|
+
/** The name of the tool that runs a plan of operations. */
|
|
7
|
+
export declare const TRANSACTION_TOOL = "transaction";
|
|
8
|
+
/** The name of the tool that answers who the agent acts for. */
|
|
9
|
+
export declare const ME_TOOL = "me";
|
|
10
|
+
/** Every tool name the catalog can offer. */
|
|
11
|
+
export type McpToolName = OperationFamily | typeof TRANSACTION_TOOL | typeof ME_TOOL;
|
|
12
|
+
/** An MCP tool's behaviour hints. */
|
|
13
|
+
export type McpToolAnnotations = {
|
|
14
|
+
readonly readOnlyHint: boolean;
|
|
15
|
+
readonly destructiveHint?: boolean;
|
|
16
|
+
readonly openWorldHint: boolean;
|
|
17
|
+
};
|
|
18
|
+
/** A JSON Schema object, as MCP carries it. */
|
|
19
|
+
export type JsonSchemaObject = {
|
|
20
|
+
readonly type: "object";
|
|
21
|
+
readonly properties: Readonly<Record<string, object>>;
|
|
22
|
+
readonly required?: readonly string[];
|
|
23
|
+
readonly additionalProperties?: boolean;
|
|
24
|
+
};
|
|
25
|
+
/** One tool as `tools/list` answers it. */
|
|
26
|
+
export type McpToolDefinition = {
|
|
27
|
+
readonly name: McpToolName;
|
|
28
|
+
readonly title: string;
|
|
29
|
+
readonly description: string;
|
|
30
|
+
readonly inputSchema: JsonSchemaObject;
|
|
31
|
+
readonly outputSchema: JsonSchemaObject;
|
|
32
|
+
readonly annotations: McpToolAnnotations;
|
|
33
|
+
};
|
|
34
|
+
/** The URI of the MCP resource that holds the Agent Contract. */
|
|
35
|
+
export declare const CONTRACT_RESOURCE_URI = "aventara://contract";
|
|
36
|
+
/** What the catalog is compiled from: the agent binding's surface, its plan limit, and whether `me` is offered. */
|
|
37
|
+
export type CatalogSource = {
|
|
38
|
+
readonly surface: ReturnType<AvProtocolBinding["surface"]>;
|
|
39
|
+
readonly maxTransactionOperations: number;
|
|
40
|
+
/** The access provider states its `client`, so `me` has an answer. */
|
|
41
|
+
readonly me?: boolean;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The tools the agent binding's surface supports: one per family it offers, in
|
|
45
|
+
* the protocol's family order, then `transaction` when the surface has the
|
|
46
|
+
* transactions route and at least one operation, then `me` when the access
|
|
47
|
+
* provider states its `client`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function compileToolCatalog(source: CatalogSource): readonly McpToolDefinition[];
|