@cleocode/lafs-protocol 0.5.0 → 1.0.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/LICENSE +0 -0
- package/README.md +0 -0
- package/dist/examples/discovery-server.d.ts +8 -0
- package/dist/examples/discovery-server.js +216 -0
- package/dist/examples/mcp-lafs-client.d.ts +10 -0
- package/dist/examples/mcp-lafs-client.js +427 -0
- package/dist/examples/mcp-lafs-server.d.ts +10 -0
- package/dist/examples/mcp-lafs-server.js +358 -0
- package/dist/schemas/v1/envelope.schema.json +0 -0
- package/dist/schemas/v1/error-registry.json +0 -0
- package/dist/src/budgetEnforcement.d.ts +84 -0
- package/dist/src/budgetEnforcement.js +328 -0
- package/dist/src/cli.d.ts +0 -0
- package/dist/src/cli.js +0 -0
- package/dist/src/conformance.d.ts +0 -0
- package/dist/src/conformance.js +0 -0
- package/dist/src/discovery.d.ts +127 -0
- package/dist/src/discovery.js +304 -0
- package/dist/src/errorRegistry.d.ts +0 -0
- package/dist/src/errorRegistry.js +0 -0
- package/dist/src/flagSemantics.d.ts +0 -0
- package/dist/src/flagSemantics.js +0 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/mcpAdapter.d.ts +28 -0
- package/dist/src/mcpAdapter.js +281 -0
- package/dist/src/tokenEstimator.d.ts +87 -0
- package/dist/src/tokenEstimator.js +238 -0
- package/dist/src/types.d.ts +25 -0
- package/dist/src/types.js +0 -0
- package/dist/src/validateEnvelope.d.ts +0 -0
- package/dist/src/validateEnvelope.js +0 -0
- package/lafs.md +164 -0
- package/package.json +8 -3
- package/schemas/v1/context-ledger.schema.json +0 -0
- package/schemas/v1/discovery.schema.json +132 -0
- package/schemas/v1/envelope.schema.json +0 -0
- package/schemas/v1/error-registry.json +0 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAFS Agent Discovery - Express/Fastify Middleware
|
|
3
|
+
* Serves discovery document at /.well-known/lafs.json
|
|
4
|
+
*/
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { createHash } from "crypto";
|
|
7
|
+
import { readFileSync } from "fs";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import { dirname, join } from "path";
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
// Handle ESM/CommonJS interop for AJV
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const AjvModule = require("ajv");
|
|
15
|
+
const AddFormatsModule = require("ajv-formats");
|
|
16
|
+
const AjvCtor = (typeof AjvModule === "function" ? AjvModule : AjvModule.default);
|
|
17
|
+
const addFormats = (typeof AddFormatsModule === "function" ? AddFormatsModule : AddFormatsModule.default);
|
|
18
|
+
let ajvInstance = null;
|
|
19
|
+
let validateDiscovery = null;
|
|
20
|
+
/**
|
|
21
|
+
* Initialize AJV validator for discovery documents
|
|
22
|
+
*/
|
|
23
|
+
function initValidator() {
|
|
24
|
+
if (ajvInstance && validateDiscovery)
|
|
25
|
+
return;
|
|
26
|
+
ajvInstance = new AjvCtor({ strict: true, allErrors: true });
|
|
27
|
+
addFormats(ajvInstance);
|
|
28
|
+
try {
|
|
29
|
+
// Try to load schema from schemas directory
|
|
30
|
+
const schemaPath = join(__dirname, "..", "..", "schemas", "v1", "discovery.schema.json");
|
|
31
|
+
const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
|
|
32
|
+
validateDiscovery = ajvInstance.compile(schema);
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
// Fallback to inline schema if file not found
|
|
36
|
+
const fallbackSchema = {
|
|
37
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
38
|
+
type: "object",
|
|
39
|
+
required: ["$schema", "lafs_version", "service", "capabilities", "endpoints"],
|
|
40
|
+
properties: {
|
|
41
|
+
$schema: { type: "string", format: "uri" },
|
|
42
|
+
lafs_version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
|
|
43
|
+
service: {
|
|
44
|
+
type: "object",
|
|
45
|
+
required: ["name", "version"],
|
|
46
|
+
properties: {
|
|
47
|
+
name: { type: "string", minLength: 1 },
|
|
48
|
+
version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
|
|
49
|
+
description: { type: "string" }
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
capabilities: {
|
|
53
|
+
type: "array",
|
|
54
|
+
items: {
|
|
55
|
+
type: "object",
|
|
56
|
+
required: ["name", "version", "operations"],
|
|
57
|
+
properties: {
|
|
58
|
+
name: { type: "string", minLength: 1 },
|
|
59
|
+
version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
|
|
60
|
+
description: { type: "string" },
|
|
61
|
+
operations: { type: "array", items: { type: "string" } },
|
|
62
|
+
optional: { type: "boolean" }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
endpoints: {
|
|
67
|
+
type: "object",
|
|
68
|
+
required: ["envelope", "discovery"],
|
|
69
|
+
properties: {
|
|
70
|
+
envelope: { type: "string", minLength: 1 },
|
|
71
|
+
context: { type: "string", minLength: 1 },
|
|
72
|
+
discovery: { type: "string", minLength: 1 }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
validateDiscovery = ajvInstance.compile(fallbackSchema);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Build absolute URL from base and path
|
|
82
|
+
*/
|
|
83
|
+
function buildUrl(base, path, req) {
|
|
84
|
+
// If path is already absolute, return it
|
|
85
|
+
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
86
|
+
return path;
|
|
87
|
+
}
|
|
88
|
+
// If base is provided, use it
|
|
89
|
+
if (base) {
|
|
90
|
+
const separator = base.endsWith("/") || path.startsWith("/") ? "" : "/";
|
|
91
|
+
return `${base}${separator}${path}`;
|
|
92
|
+
}
|
|
93
|
+
// Otherwise try to construct from request
|
|
94
|
+
if (req) {
|
|
95
|
+
const protocol = req.headers["x-forwarded-proto"] || req.protocol || "http";
|
|
96
|
+
const host = req.headers.host || "localhost";
|
|
97
|
+
const separator = path.startsWith("/") ? "" : "/";
|
|
98
|
+
return `${protocol}://${host}${separator}${path}`;
|
|
99
|
+
}
|
|
100
|
+
// Fallback to relative path
|
|
101
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Generate ETag from document content
|
|
105
|
+
*/
|
|
106
|
+
function generateETag(content) {
|
|
107
|
+
return `"${createHash("sha256").update(content).digest("hex").slice(0, 32)}"`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Build discovery document from configuration
|
|
111
|
+
*/
|
|
112
|
+
function buildDiscoveryDocument(config, req) {
|
|
113
|
+
const schemaUrl = config.schemaUrl || "https://lafs.dev/schemas/v1/discovery.schema.json";
|
|
114
|
+
const lafsVersion = config.lafsVersion || "1.0.0";
|
|
115
|
+
return {
|
|
116
|
+
$schema: schemaUrl,
|
|
117
|
+
lafs_version: lafsVersion,
|
|
118
|
+
service: config.service,
|
|
119
|
+
capabilities: config.capabilities,
|
|
120
|
+
endpoints: {
|
|
121
|
+
envelope: buildUrl(config.baseUrl, config.endpoints.envelope, req),
|
|
122
|
+
context: config.endpoints.context
|
|
123
|
+
? buildUrl(config.baseUrl, config.endpoints.context, req)
|
|
124
|
+
: undefined,
|
|
125
|
+
discovery: config.endpoints.discovery
|
|
126
|
+
? buildUrl(config.baseUrl, config.endpoints.discovery, req)
|
|
127
|
+
: buildUrl(config.baseUrl, "/.well-known/lafs.json", req)
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Validate discovery document against schema
|
|
133
|
+
*/
|
|
134
|
+
function validateDocument(doc) {
|
|
135
|
+
initValidator();
|
|
136
|
+
if (!validateDiscovery) {
|
|
137
|
+
throw new Error("Discovery document validator not initialized");
|
|
138
|
+
}
|
|
139
|
+
const valid = validateDiscovery(doc);
|
|
140
|
+
if (!valid) {
|
|
141
|
+
const errors = validateDiscovery.errors;
|
|
142
|
+
const errorMessages = errors?.map((e) => `${e.instancePath || "root"}: ${e.message}`).join("; ");
|
|
143
|
+
throw new Error(`Discovery document validation failed: ${errorMessages}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Create Express middleware for serving LAFS discovery document
|
|
148
|
+
*
|
|
149
|
+
* @param config - Discovery configuration
|
|
150
|
+
* @param options - Middleware options
|
|
151
|
+
* @returns Express RequestHandler
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```typescript
|
|
155
|
+
* import express from "express";
|
|
156
|
+
* import { discoveryMiddleware } from "./discovery.js";
|
|
157
|
+
*
|
|
158
|
+
* const app = express();
|
|
159
|
+
*
|
|
160
|
+
* app.use(discoveryMiddleware({
|
|
161
|
+
* service: {
|
|
162
|
+
* name: "my-lafs-service",
|
|
163
|
+
* version: "1.0.0",
|
|
164
|
+
* description: "A LAFS-compliant API service"
|
|
165
|
+
* },
|
|
166
|
+
* capabilities: [
|
|
167
|
+
* {
|
|
168
|
+
* name: "envelope-processor",
|
|
169
|
+
* version: "1.0.0",
|
|
170
|
+
* operations: ["process", "validate"],
|
|
171
|
+
* description: "Process LAFS envelopes"
|
|
172
|
+
* }
|
|
173
|
+
* ],
|
|
174
|
+
* endpoints: {
|
|
175
|
+
* envelope: "/api/v1/envelope",
|
|
176
|
+
* context: "/api/v1/context"
|
|
177
|
+
* }
|
|
178
|
+
* }));
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
export function discoveryMiddleware(config, options = {}) {
|
|
182
|
+
const path = options.path || "/.well-known/lafs.json";
|
|
183
|
+
const enableHead = options.enableHead !== false;
|
|
184
|
+
const enableEtag = options.enableEtag !== false;
|
|
185
|
+
const cacheMaxAge = config.cacheMaxAge || 3600;
|
|
186
|
+
// Validate configuration
|
|
187
|
+
if (!config.service?.name || !config.service?.version) {
|
|
188
|
+
throw new Error("Discovery config requires service.name and service.version");
|
|
189
|
+
}
|
|
190
|
+
if (!Array.isArray(config.capabilities)) {
|
|
191
|
+
throw new Error("Discovery config requires capabilities array");
|
|
192
|
+
}
|
|
193
|
+
if (!config.endpoints?.envelope) {
|
|
194
|
+
throw new Error("Discovery config requires endpoints.envelope");
|
|
195
|
+
}
|
|
196
|
+
return function discoveryHandler(req, res, next) {
|
|
197
|
+
// Only handle requests to the discovery path
|
|
198
|
+
if (req.path !== path) {
|
|
199
|
+
next();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Handle HEAD requests
|
|
203
|
+
if (req.method === "HEAD") {
|
|
204
|
+
if (!enableHead) {
|
|
205
|
+
res.status(405).json({
|
|
206
|
+
error: "Method Not Allowed",
|
|
207
|
+
message: "HEAD requests are disabled for this endpoint"
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// For HEAD, we still need to build the document to get the ETag
|
|
212
|
+
const doc = buildDiscoveryDocument(config, req);
|
|
213
|
+
const json = JSON.stringify(doc);
|
|
214
|
+
// Generate stable ETag from config hash (not request-dependent document)
|
|
215
|
+
const configHash = generateETag(JSON.stringify({
|
|
216
|
+
schemaUrl: config.schemaUrl,
|
|
217
|
+
lafsVersion: config.lafsVersion,
|
|
218
|
+
service: config.service,
|
|
219
|
+
capabilities: config.capabilities,
|
|
220
|
+
endpoints: config.endpoints,
|
|
221
|
+
cacheMaxAge: config.cacheMaxAge
|
|
222
|
+
}));
|
|
223
|
+
const etag = enableEtag ? configHash : undefined;
|
|
224
|
+
res.set({
|
|
225
|
+
"Content-Type": "application/json",
|
|
226
|
+
"Cache-Control": `public, max-age=${cacheMaxAge}`,
|
|
227
|
+
...(etag && { "ETag": etag }),
|
|
228
|
+
"Content-Length": Buffer.byteLength(json)
|
|
229
|
+
});
|
|
230
|
+
res.status(200).end();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// Only handle GET requests
|
|
234
|
+
if (req.method !== "GET") {
|
|
235
|
+
res.status(405).json({
|
|
236
|
+
error: "Method Not Allowed",
|
|
237
|
+
message: `Method ${req.method} not allowed. Use GET or HEAD.`
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
// Build discovery document
|
|
243
|
+
const doc = buildDiscoveryDocument(config, req);
|
|
244
|
+
// Validate against schema
|
|
245
|
+
validateDocument(doc);
|
|
246
|
+
// Serialize document
|
|
247
|
+
const json = JSON.stringify(doc);
|
|
248
|
+
// Generate ETag from config hash (stable) rather than request-dependent document
|
|
249
|
+
// This ensures ETag is consistent across requests even when URLs are constructed from request
|
|
250
|
+
const configHash = generateETag(JSON.stringify({
|
|
251
|
+
schemaUrl: config.schemaUrl,
|
|
252
|
+
lafsVersion: config.lafsVersion,
|
|
253
|
+
service: config.service,
|
|
254
|
+
capabilities: config.capabilities,
|
|
255
|
+
endpoints: config.endpoints,
|
|
256
|
+
cacheMaxAge: config.cacheMaxAge
|
|
257
|
+
}));
|
|
258
|
+
const etag = enableEtag ? configHash : undefined;
|
|
259
|
+
// Check If-None-Match for conditional request
|
|
260
|
+
if (enableEtag && req.headers["if-none-match"] === etag) {
|
|
261
|
+
res.status(304).end();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Set response headers
|
|
265
|
+
const headers = {
|
|
266
|
+
"Content-Type": "application/json",
|
|
267
|
+
"Cache-Control": `public, max-age=${cacheMaxAge}`,
|
|
268
|
+
...config.headers
|
|
269
|
+
};
|
|
270
|
+
if (etag) {
|
|
271
|
+
headers["ETag"] = etag;
|
|
272
|
+
}
|
|
273
|
+
res.set(headers);
|
|
274
|
+
res.status(200).send(json);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
next(error);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Fastify plugin for LAFS discovery (for Fastify users)
|
|
283
|
+
*
|
|
284
|
+
* @param fastify - Fastify instance
|
|
285
|
+
* @param options - Plugin options
|
|
286
|
+
*/
|
|
287
|
+
export async function discoveryFastifyPlugin(fastify, options) {
|
|
288
|
+
const path = options.path || "/.well-known/lafs.json";
|
|
289
|
+
const config = options.config;
|
|
290
|
+
const cacheMaxAge = config.cacheMaxAge || 3600;
|
|
291
|
+
const handler = async (request, reply) => {
|
|
292
|
+
const doc = buildDiscoveryDocument(config, request.raw);
|
|
293
|
+
validateDocument(doc);
|
|
294
|
+
const json = JSON.stringify(doc);
|
|
295
|
+
const etag = generateETag(json);
|
|
296
|
+
reply.header("Content-Type", "application/json");
|
|
297
|
+
reply.header("Cache-Control", `public, max-age=${cacheMaxAge}`);
|
|
298
|
+
reply.header("ETag", etag);
|
|
299
|
+
return doc;
|
|
300
|
+
};
|
|
301
|
+
// Note: Actual route registration depends on Fastify's API
|
|
302
|
+
// This is a type-safe signature for the plugin
|
|
303
|
+
}
|
|
304
|
+
export default discoveryMiddleware;
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/dist/src/index.d.ts
CHANGED
|
@@ -3,3 +3,7 @@ export * from "./errorRegistry.js";
|
|
|
3
3
|
export * from "./validateEnvelope.js";
|
|
4
4
|
export * from "./flagSemantics.js";
|
|
5
5
|
export * from "./conformance.js";
|
|
6
|
+
export * from "./tokenEstimator.js";
|
|
7
|
+
export * from "./budgetEnforcement.js";
|
|
8
|
+
export * from "./mcpAdapter.js";
|
|
9
|
+
export * from "./discovery.js";
|
package/dist/src/index.js
CHANGED
|
@@ -3,3 +3,7 @@ export * from "./errorRegistry.js";
|
|
|
3
3
|
export * from "./validateEnvelope.js";
|
|
4
4
|
export * from "./flagSemantics.js";
|
|
5
5
|
export * from "./conformance.js";
|
|
6
|
+
export * from "./tokenEstimator.js";
|
|
7
|
+
export * from "./budgetEnforcement.js";
|
|
8
|
+
export * from "./mcpAdapter.js";
|
|
9
|
+
export * from "./discovery.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CallToolResult, TextContent } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import type { LAFSEnvelope, LAFSError } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Wrap MCP tool result in LAFS envelope
|
|
5
|
+
*
|
|
6
|
+
* @param mcpResult - The raw MCP CallToolResult
|
|
7
|
+
* @param operation - The operation name (tool name)
|
|
8
|
+
* @param budget - Optional token budget for response
|
|
9
|
+
* @returns LAFS-compliant envelope
|
|
10
|
+
*/
|
|
11
|
+
export declare function wrapMCPResult(mcpResult: CallToolResult, operation: string, budget?: number): LAFSEnvelope;
|
|
12
|
+
/**
|
|
13
|
+
* Create a LAFS error envelope for MCP adapter errors
|
|
14
|
+
*
|
|
15
|
+
* @param message - Error message
|
|
16
|
+
* @param operation - The operation being performed
|
|
17
|
+
* @param category - Error category
|
|
18
|
+
* @returns LAFS error envelope
|
|
19
|
+
*/
|
|
20
|
+
export declare function createAdapterErrorEnvelope(message: string, operation: string, category?: LAFSError["category"]): LAFSEnvelope;
|
|
21
|
+
/**
|
|
22
|
+
* Type guard to check if content is TextContent
|
|
23
|
+
*/
|
|
24
|
+
export declare function isTextContent(content: unknown): content is TextContent;
|
|
25
|
+
/**
|
|
26
|
+
* Parse MCP text content as JSON if possible
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseMCPTextContent(content: TextContent): unknown;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Extract result from MCP content array
|
|
4
|
+
* Attempts to parse JSON content, falls back to text representation
|
|
5
|
+
*/
|
|
6
|
+
function extractResultFromContent(content) {
|
|
7
|
+
if (!content || content.length === 0) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
// Combine all text content
|
|
11
|
+
const textParts = [];
|
|
12
|
+
const otherContent = [];
|
|
13
|
+
for (const item of content) {
|
|
14
|
+
if (item.type === "text" && item.text) {
|
|
15
|
+
textParts.push(item.text);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
otherContent.push(item);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Try to parse as JSON if single text item looks like JSON
|
|
22
|
+
if (textParts.length === 1) {
|
|
23
|
+
const text = textParts[0]?.trim() ?? "";
|
|
24
|
+
if (text.startsWith("{") || text.startsWith("[")) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(text);
|
|
27
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Not valid JSON, treat as text
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Combine into result object
|
|
37
|
+
const result = {};
|
|
38
|
+
if (textParts.length > 0) {
|
|
39
|
+
result.text = textParts.length === 1 ? textParts[0] : textParts.join("\n");
|
|
40
|
+
}
|
|
41
|
+
if (otherContent.length > 0) {
|
|
42
|
+
result.content = otherContent;
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Estimate token count from content
|
|
48
|
+
* Rough estimation: ~4 characters per token
|
|
49
|
+
*/
|
|
50
|
+
function estimateTokens(content) {
|
|
51
|
+
if (!content)
|
|
52
|
+
return 0;
|
|
53
|
+
const jsonString = JSON.stringify(content);
|
|
54
|
+
return Math.ceil(jsonString.length / 4);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Truncate content to fit within budget
|
|
58
|
+
*/
|
|
59
|
+
function truncateToBudget(content, budget) {
|
|
60
|
+
if (!content)
|
|
61
|
+
return { result: null, truncated: false, originalEstimate: 0 };
|
|
62
|
+
const originalEstimate = estimateTokens(content);
|
|
63
|
+
if (originalEstimate <= budget) {
|
|
64
|
+
return { result: content, truncated: false, originalEstimate };
|
|
65
|
+
}
|
|
66
|
+
// Calculate truncation ratio
|
|
67
|
+
const ratio = budget / originalEstimate;
|
|
68
|
+
const jsonString = JSON.stringify(content);
|
|
69
|
+
const targetLength = Math.floor(jsonString.length * ratio);
|
|
70
|
+
// Truncate the string and try to make it valid JSON
|
|
71
|
+
let truncated = jsonString.slice(0, targetLength);
|
|
72
|
+
// Close any open structures
|
|
73
|
+
const openBraces = (truncated.match(/\{/g) || []).length - (truncated.match(/\}/g) || []).length;
|
|
74
|
+
const openBrackets = (truncated.match(/\[/g) || []).length - (truncated.match(/\]/g) || []).length;
|
|
75
|
+
truncated += "}".repeat(Math.max(0, openBraces));
|
|
76
|
+
truncated += "]".repeat(Math.max(0, openBrackets));
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(truncated);
|
|
79
|
+
// Add truncation notice
|
|
80
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
81
|
+
parsed["_truncated"] = true;
|
|
82
|
+
parsed["_originalTokens"] = originalEstimate;
|
|
83
|
+
parsed["_budget"] = budget;
|
|
84
|
+
}
|
|
85
|
+
return { result: parsed, truncated: true, originalEstimate };
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// If parsing fails, return minimal result
|
|
89
|
+
return {
|
|
90
|
+
result: {
|
|
91
|
+
_truncated: true,
|
|
92
|
+
_error: "Content truncated due to budget constraints",
|
|
93
|
+
_originalTokens: originalEstimate,
|
|
94
|
+
_budget: budget,
|
|
95
|
+
},
|
|
96
|
+
truncated: true,
|
|
97
|
+
originalEstimate,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Convert MCP error to LAFS error format
|
|
103
|
+
*/
|
|
104
|
+
function convertMCPErrorToLAFS(mcpResult, operation) {
|
|
105
|
+
const content = mcpResult.content;
|
|
106
|
+
const errorText = content
|
|
107
|
+
.filter((item) => item.type === "text" && typeof item.text === "string")
|
|
108
|
+
.map((item) => item.text)
|
|
109
|
+
.join("\n");
|
|
110
|
+
// Determine error category based on error text
|
|
111
|
+
let category = "INTERNAL";
|
|
112
|
+
let code = "E_MCP_INTERNAL_ERROR";
|
|
113
|
+
let retryable = false;
|
|
114
|
+
let retryAfterMs = null;
|
|
115
|
+
const errorLower = errorText.toLowerCase();
|
|
116
|
+
if (errorLower.includes("not found") || errorLower.includes("doesn't exist") || errorLower.includes("does not exist")) {
|
|
117
|
+
category = "NOT_FOUND";
|
|
118
|
+
code = "E_MCP_NOT_FOUND";
|
|
119
|
+
}
|
|
120
|
+
else if (errorLower.includes("rate limit") || errorLower.includes("too many requests")) {
|
|
121
|
+
category = "RATE_LIMIT";
|
|
122
|
+
code = "E_MCP_RATE_LIMIT";
|
|
123
|
+
retryable = true;
|
|
124
|
+
retryAfterMs = 60000; // 1 minute default
|
|
125
|
+
}
|
|
126
|
+
else if (errorLower.includes("auth") || errorLower.includes("unauthorized") || errorLower.includes("forbidden")) {
|
|
127
|
+
category = "AUTH";
|
|
128
|
+
code = "E_MCP_AUTH_ERROR";
|
|
129
|
+
}
|
|
130
|
+
else if (errorLower.includes("permission") || errorLower.includes("access denied")) {
|
|
131
|
+
category = "PERMISSION";
|
|
132
|
+
code = "E_MCP_PERMISSION_DENIED";
|
|
133
|
+
}
|
|
134
|
+
else if (errorLower.includes("validation") || errorLower.includes("invalid")) {
|
|
135
|
+
category = "VALIDATION";
|
|
136
|
+
code = "E_MCP_VALIDATION_ERROR";
|
|
137
|
+
}
|
|
138
|
+
else if (errorLower.includes("timeout") || errorLower.includes("transient")) {
|
|
139
|
+
category = "TRANSIENT";
|
|
140
|
+
code = "E_MCP_TRANSIENT_ERROR";
|
|
141
|
+
retryable = true;
|
|
142
|
+
retryAfterMs = 5000; // 5 seconds
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
code,
|
|
146
|
+
message: errorText || "MCP tool execution failed",
|
|
147
|
+
category,
|
|
148
|
+
retryable,
|
|
149
|
+
retryAfterMs,
|
|
150
|
+
details: {
|
|
151
|
+
operation,
|
|
152
|
+
mcpError: true,
|
|
153
|
+
contentTypes: content.map((c) => c.type),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Wrap MCP tool result in LAFS envelope
|
|
159
|
+
*
|
|
160
|
+
* @param mcpResult - The raw MCP CallToolResult
|
|
161
|
+
* @param operation - The operation name (tool name)
|
|
162
|
+
* @param budget - Optional token budget for response
|
|
163
|
+
* @returns LAFS-compliant envelope
|
|
164
|
+
*/
|
|
165
|
+
export function wrapMCPResult(mcpResult, operation, budget) {
|
|
166
|
+
const requestId = randomUUID();
|
|
167
|
+
const timestamp = new Date().toISOString();
|
|
168
|
+
// Build base meta
|
|
169
|
+
const meta = {
|
|
170
|
+
specVersion: "1.0.0",
|
|
171
|
+
schemaVersion: "1.0.0",
|
|
172
|
+
timestamp,
|
|
173
|
+
operation,
|
|
174
|
+
requestId,
|
|
175
|
+
transport: "sdk",
|
|
176
|
+
strict: true,
|
|
177
|
+
mvi: "standard",
|
|
178
|
+
contextVersion: 1,
|
|
179
|
+
};
|
|
180
|
+
// Handle MCP error
|
|
181
|
+
if (mcpResult.isError) {
|
|
182
|
+
const error = convertMCPErrorToLAFS(mcpResult, operation);
|
|
183
|
+
return {
|
|
184
|
+
$schema: "https://lafs.dev/schemas/v1/envelope.schema.json",
|
|
185
|
+
_meta: meta,
|
|
186
|
+
success: false,
|
|
187
|
+
result: null,
|
|
188
|
+
error,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// Extract result from MCP content
|
|
192
|
+
let result = extractResultFromContent(mcpResult.content);
|
|
193
|
+
let truncated = false;
|
|
194
|
+
let originalEstimate = 0;
|
|
195
|
+
let extensions;
|
|
196
|
+
// Apply budget enforcement if specified
|
|
197
|
+
if (budget !== undefined && budget > 0) {
|
|
198
|
+
const budgetResult = truncateToBudget(result, budget);
|
|
199
|
+
result = budgetResult.result;
|
|
200
|
+
truncated = budgetResult.truncated;
|
|
201
|
+
originalEstimate = budgetResult.originalEstimate;
|
|
202
|
+
// Put token estimate in extensions to comply with strict schema
|
|
203
|
+
extensions = {
|
|
204
|
+
"x-mcp-token-estimate": {
|
|
205
|
+
estimated: truncated ? budget : originalEstimate,
|
|
206
|
+
truncated,
|
|
207
|
+
originalEstimate: truncated ? originalEstimate : undefined,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
$schema: "https://lafs.dev/schemas/v1/envelope.schema.json",
|
|
213
|
+
_meta: meta,
|
|
214
|
+
success: true,
|
|
215
|
+
result,
|
|
216
|
+
error: null,
|
|
217
|
+
_extensions: extensions,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Create a LAFS error envelope for MCP adapter errors
|
|
222
|
+
*
|
|
223
|
+
* @param message - Error message
|
|
224
|
+
* @param operation - The operation being performed
|
|
225
|
+
* @param category - Error category
|
|
226
|
+
* @returns LAFS error envelope
|
|
227
|
+
*/
|
|
228
|
+
export function createAdapterErrorEnvelope(message, operation, category = "INTERNAL") {
|
|
229
|
+
const requestId = randomUUID();
|
|
230
|
+
const timestamp = new Date().toISOString();
|
|
231
|
+
const error = {
|
|
232
|
+
code: "E_MCP_ADAPTER_ERROR",
|
|
233
|
+
message,
|
|
234
|
+
category,
|
|
235
|
+
retryable: category === "TRANSIENT" || category === "RATE_LIMIT",
|
|
236
|
+
retryAfterMs: category === "RATE_LIMIT" ? 60000 : category === "TRANSIENT" ? 5000 : null,
|
|
237
|
+
details: {
|
|
238
|
+
operation,
|
|
239
|
+
adapterError: true,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
return {
|
|
243
|
+
$schema: "https://lafs.dev/schemas/v1/envelope.schema.json",
|
|
244
|
+
_meta: {
|
|
245
|
+
specVersion: "1.0.0",
|
|
246
|
+
schemaVersion: "1.0.0",
|
|
247
|
+
timestamp,
|
|
248
|
+
operation,
|
|
249
|
+
requestId,
|
|
250
|
+
transport: "sdk",
|
|
251
|
+
strict: true,
|
|
252
|
+
mvi: "standard",
|
|
253
|
+
contextVersion: 1,
|
|
254
|
+
},
|
|
255
|
+
success: false,
|
|
256
|
+
result: null,
|
|
257
|
+
error,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Type guard to check if content is TextContent
|
|
262
|
+
*/
|
|
263
|
+
export function isTextContent(content) {
|
|
264
|
+
return (typeof content === "object" &&
|
|
265
|
+
content !== null &&
|
|
266
|
+
"type" in content &&
|
|
267
|
+
content.type === "text" &&
|
|
268
|
+
"text" in content &&
|
|
269
|
+
typeof content.text === "string");
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Parse MCP text content as JSON if possible
|
|
273
|
+
*/
|
|
274
|
+
export function parseMCPTextContent(content) {
|
|
275
|
+
try {
|
|
276
|
+
return JSON.parse(content.text);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return content.text;
|
|
280
|
+
}
|
|
281
|
+
}
|