@unified-llm/core
Version:
Unified LLM interface (in-memory).
295 lines • 12.3 kB
JavaScript
// src/utils/mcp-utils.ts
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
export function createMcpTransport(server) {
var _a;
switch (server.type) {
case "stdio": {
if (!server.command) {
throw new Error(`[mcp:${server.name}] stdio transport requires command`);
}
const env = server.env
? { ...getDefaultEnvironment(), ...server.env }
: undefined;
return new StdioClientTransport({
command: server.command,
args: (_a = server.args) !== null && _a !== void 0 ? _a : [],
env,
});
}
case "streamable_http": {
if (!server.url) {
throw new Error(`[mcp:${server.name}] streamable_http transport requires url`);
}
const requestInit = server.headers
? { headers: server.headers }
: undefined;
return new StreamableHTTPClientTransport(new URL(server.url), {
requestInit,
});
}
// deprecated
case "sse": {
if (!server.url) {
throw new Error(`[mcp:${server.name}] sse transport requires url`);
}
const requestInit = server.headers
? { headers: server.headers }
: undefined;
const eventSourceInit = server.headers
? { headers: server.headers }
: undefined;
return new SSEClientTransport(new URL(server.url), {
requestInit,
eventSourceInit,
});
}
default: {
const _exhaustive = server.type;
throw new Error(`Unsupported MCP transport type: ${String(_exhaustive)}`);
}
}
}
const DEFAULTS = {
maxTextChars: 12000,
maxStringChars: 4000,
binaryOmitThresholdChars: 2000,
binaryReplacement: "",
fingerprintSampleChars: 64000,
enableFingerprint: true,
sanitizeStructuredContent: false,
structuredContentMaxStringChars: 8000,
};
function truncate(s, max) {
if (s.length <= max)
return s;
return s.slice(0, max) + "…[[TRUNCATED]]";
}
/**
* Edge/Node 両対応の軽量指紋(非暗号学的)。
* 同一データっぽさの判別・デバッグ用途向け。
*/
function fnv1a32Hex(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, "0");
}
function mergeMeta(existing, patch) {
const base = existing && typeof existing === "object" && !Array.isArray(existing)
? existing
: {};
return { ...base, ...patch };
}
function summarizeOmittedBinary(data, opts) {
const sample = data.slice(0, opts.fingerprintSampleChars);
const summary = {
omitted: true,
originalLength: data.length,
sampledChars: sample.length,
isSampled: data.length > sample.length,
};
if (opts.enableFingerprint) {
summary.fingerprint = fnv1a32Hex(sample);
summary.fingerprintAlg = "fnv1a32(sample)";
}
return summary;
}
function sanitizeStructuredContent(value, maxStringChars, seen) {
if (value === null || value === undefined)
return value;
if (typeof value === "string")
return truncate(value, maxStringChars);
if (typeof value === "number" || typeof value === "boolean")
return value;
if (Array.isArray(value)) {
return value.map((v) => sanitizeStructuredContent(v, maxStringChars, seen));
}
if (typeof value === "object") {
if (seen.has(value))
return "[[CIRCULAR]]";
seen.add(value);
const obj = value;
const out = {};
for (const k of Object.keys(obj)) {
out[k] = sanitizeStructuredContent(obj[k], maxStringChars, seen);
}
return out;
}
return String(value);
}
/**
* MCP の `client.callTool()` が返す結果を、
* **(1) rawResult と (2) sanitizedResult に分離**して返します。
*
* ## なぜ分離するのか
* - image/audio/resource.blob が base64 を含む場合、そのまま LLM 履歴に入れると
* トークン爆発・履歴欠落・API失敗の原因になります。
* - しかし後段でバイナリが必要なケース(保存/解析/復元)があるため、
* **raw を捨てずに保持**できる形が必要です。
*
* ## MCPプロトコル互換性(重要)
* - `content[]` は維持します。
* - `type:"image"` / `type:"audio"` の `data` は **string のまま維持**します(必須フィールド)。
* 省略する場合でも `data` を削除せず、`binaryReplacement`(デフォルトは空文字)に置換します。
* - `type:"resource"` の `resource.blob` も同様に **string のまま維持**します(必須フィールド)。
* - 省略・短縮した事実や指紋は `_meta.__sanitizer` に格納します(`_meta` は MCP が許容する拡張領域)。
*
* ## 戻り値の使い方(推奨)
* - LLM へ渡す/履歴に積む:`sanitizedResult` だけ
* - デバッグ保存/後段処理:`rawResult`(必要に応じて別ストレージへ)
*
* @param result MCP の callTool() 戻り値(そのまま渡してOK)
* @param options サニタイズ閾値・置換文字列等
*/
export function sanitizeToolCallResult(result, options) {
var _a;
const opts = { ...DEFAULTS, ...(options !== null && options !== void 0 ? options : {}) };
// 仕様外の形は触らず、そのまま返す(「壊さない」優先)
if (!result || typeof result !== "object" || Array.isArray(result)) {
return { rawResult: result, sanitizedResult: result, isSanitized: false };
}
const r = result;
const content = Array.isArray(r.content) ? r.content : null;
// CallToolResult でない(content が無い)場合も、そのまま返す
if (!content) {
return { rawResult: result, sanitizedResult: result, isSanitized: false };
}
let changed = false;
const sanitizedContent = content.map((block) => {
var _a, _b, _c, _d, _e;
if (!block || typeof block !== "object" || Array.isArray(block))
return block;
const b = block;
const type = typeof b.type === "string" ? b.type : "unknown";
if (type === "text") {
const text = typeof b.text === "string" ? b.text : "";
if (text.length > opts.maxTextChars) {
changed = true;
return {
...b,
text: truncate(text, opts.maxTextChars),
_meta: mergeMeta(b._meta, {
__sanitizer: mergeMeta((_a = b._meta) === null || _a === void 0 ? void 0 : _a.__sanitizer, {
text: { truncated: true, originalLength: text.length, maxChars: opts.maxTextChars },
}),
}),
};
}
return b;
}
if (type === "image" || type === "audio") {
const data = typeof b.data === "string" ? b.data : "";
if (data.length >= opts.binaryOmitThresholdChars) {
changed = true;
const summary = summarizeOmittedBinary(data, opts);
return {
...b,
// MCP互換: data は string を維持(削除しない)
data: opts.binaryReplacement,
_meta: mergeMeta(b._meta, {
__sanitizer: mergeMeta((_b = b._meta) === null || _b === void 0 ? void 0 : _b.__sanitizer, {
data: summary,
}),
}),
};
}
return b;
}
if (type === "resource") {
const resource = b.resource;
if (resource && typeof resource === "object" && !Array.isArray(resource)) {
const rsrc = resource;
// text resource
if (typeof rsrc.text === "string" && rsrc.text.length > opts.maxTextChars) {
changed = true;
const originalLength = rsrc.text.length;
return {
...b,
resource: {
...rsrc,
text: truncate(rsrc.text, opts.maxTextChars),
_meta: mergeMeta(rsrc._meta, {
__sanitizer: mergeMeta((_c = rsrc._meta) === null || _c === void 0 ? void 0 : _c.__sanitizer, {
text: { truncated: true, originalLength, maxChars: opts.maxTextChars },
}),
}),
},
};
}
// blob resource
if (typeof rsrc.blob === "string" && rsrc.blob.length >= opts.binaryOmitThresholdChars) {
changed = true;
const blob = rsrc.blob;
const summary = summarizeOmittedBinary(blob, opts);
return {
...b,
resource: {
...rsrc,
// MCP互換: blob は string を維持(削除しない)
blob: opts.binaryReplacement,
_meta: mergeMeta(rsrc._meta, {
__sanitizer: mergeMeta((_d = rsrc._meta) === null || _d === void 0 ? void 0 : _d.__sanitizer, {
blob: summary,
}),
}),
},
};
}
}
return b;
}
if (type === "resource_link") {
// 文字列を短縮(型は維持)
let localChanged = false;
const next = { ...b };
for (const key of ["title", "description", "name", "uri"]) {
const v = next[key];
if (typeof v === "string" && v.length > opts.maxStringChars) {
localChanged = true;
next[key] = truncate(v, opts.maxStringChars);
}
}
if (localChanged) {
changed = true;
next._meta = mergeMeta(b._meta, {
__sanitizer: mergeMeta((_e = b._meta) === null || _e === void 0 ? void 0 : _e.__sanitizer, {
strings: { truncated: true, maxChars: opts.maxStringChars },
}),
});
return next;
}
return b;
}
// unknown type は壊さない(サーバ独自拡張を尊重)
return b;
});
if (!changed) {
// 変更が無いなら参照も同一にして「合致」を厳密に表現できるようにする
return { rawResult: result, sanitizedResult: result, isSanitized: false };
}
const nextStructuredContent = opts.sanitizeStructuredContent && r.structuredContent !== undefined
? sanitizeStructuredContent(r.structuredContent, opts.structuredContentMaxStringChars, new WeakSet())
: r.structuredContent;
const sanitizedTop = {
...r,
content: sanitizedContent,
structuredContent: nextStructuredContent,
_meta: mergeMeta(r._meta, {
__sanitizer: mergeMeta((_a = r._meta) === null || _a === void 0 ? void 0 : _a.__sanitizer, {
applied: true,
binaryOmitThresholdChars: opts.binaryOmitThresholdChars,
maxTextChars: opts.maxTextChars,
}),
}),
};
return {
rawResult: result,
sanitizedResult: sanitizedTop,
isSanitized: true,
};
}
//# sourceMappingURL=mcp-utils.js.map