194 lines
6.0 KiB
TypeScript
194 lines
6.0 KiB
TypeScript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
import { Type, type TSchema } from "@sinclair/typebox";
|
|
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
import type { ImageContent, TextContent } from "@earendil-works/pi-ai/compat";
|
|
|
|
type McpToolResult = {
|
|
content: Array<
|
|
| { type: "text"; text: string }
|
|
| { type: "image"; data: string; mimeType: string }
|
|
| Record<string, unknown>
|
|
>;
|
|
isError?: boolean;
|
|
structuredContent?: Record<string, unknown>;
|
|
};
|
|
|
|
const endpoint = process.env.SWADS_MCP_URL || "https://ads.mincode.cn/mcp";
|
|
const maxRows = Math.max(10, Number(process.env.SWADS_MCP_MAX_ROWS) || 50);
|
|
const maxTextChars = Math.max(
|
|
10_000,
|
|
Number(process.env.SWADS_MCP_MAX_TEXT_CHARS) || 60_000,
|
|
);
|
|
let clientPromise: Promise<Client> | undefined;
|
|
|
|
export function boundSwadsArguments(
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
) {
|
|
if (name !== "metrics_semantic_query") return args;
|
|
const query = args.query;
|
|
if (!query || typeof query !== "object" || Array.isArray(query)) return args;
|
|
const requested = Number((query as Record<string, unknown>).limit);
|
|
return {
|
|
...args,
|
|
query: {
|
|
...query,
|
|
limit: Number.isFinite(requested)
|
|
? Math.min(Math.max(1, requested), maxRows)
|
|
: maxRows,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function compactSwadsText(text: string) {
|
|
try {
|
|
const parsed = JSON.parse(text) as Record<string, unknown>;
|
|
let originalRows: number | undefined;
|
|
let returnedRows: number | undefined;
|
|
for (const key of ["rows", "products", "items", "data"]) {
|
|
const value = parsed[key];
|
|
if (!Array.isArray(value)) continue;
|
|
originalRows = value.length;
|
|
parsed[key] = value.slice(0, maxRows);
|
|
returnedRows = (parsed[key] as unknown[]).length;
|
|
break;
|
|
}
|
|
if (originalRows !== undefined) {
|
|
parsed.response_compaction = {
|
|
original_rows: originalRows,
|
|
returned_rows: returnedRows,
|
|
max_rows: maxRows,
|
|
note: "Result was bounded for model context safety. Use aggregate queries or narrower filters for additional detail.",
|
|
};
|
|
}
|
|
const compact = JSON.stringify(parsed);
|
|
return compact.length <= maxTextChars
|
|
? compact
|
|
: `${compact.slice(0, maxTextChars)}\n[SWADS_RESULT_TRUNCATED_AT_${maxTextChars}_CHARS]`;
|
|
} catch {
|
|
return text.length <= maxTextChars
|
|
? text
|
|
: `${text.slice(0, maxTextChars)}\n[SWADS_RESULT_TRUNCATED_AT_${maxTextChars}_CHARS]`;
|
|
}
|
|
}
|
|
|
|
async function swadsClient() {
|
|
const token = process.env.SWADS_MCP_TOKEN?.trim();
|
|
if (!token)
|
|
throw new Error(
|
|
"SWADS_MCP_NOT_CONFIGURED: set SWADS_MCP_TOKEN in the server environment",
|
|
);
|
|
clientPromise ??= (async () => {
|
|
const client = new Client({ name: "agent-studio-mini", version: "0.1.0" });
|
|
const transport = new StreamableHTTPClientTransport(new URL(endpoint), {
|
|
requestInit: { headers: { Authorization: `Bearer ${token}` } },
|
|
});
|
|
await client.connect(transport);
|
|
return client;
|
|
})().catch((error) => {
|
|
clientPromise = undefined;
|
|
throw error;
|
|
});
|
|
return clientPromise;
|
|
}
|
|
|
|
function asTool(
|
|
name: string,
|
|
description: string,
|
|
parameters: TSchema,
|
|
): ToolDefinition {
|
|
return defineTool({
|
|
name,
|
|
label: `SW Ads · ${name}`,
|
|
description,
|
|
parameters,
|
|
execute: async (_id, args, signal) => {
|
|
const client = await swadsClient();
|
|
const boundedArgs = boundSwadsArguments(
|
|
name,
|
|
args as Record<string, unknown>,
|
|
);
|
|
const result = (await client.callTool(
|
|
{ name, arguments: boundedArgs },
|
|
undefined,
|
|
{ signal },
|
|
)) as McpToolResult;
|
|
const content: Array<TextContent | ImageContent> = [];
|
|
for (const item of result.content) {
|
|
if (item.type === "text")
|
|
content.push({ type: "text", text: compactSwadsText(String(item.text)) });
|
|
else if (item.type === "image")
|
|
content.push({
|
|
type: "image",
|
|
data: String(item.data),
|
|
mimeType: String(item.mimeType),
|
|
});
|
|
else content.push({ type: "text", text: JSON.stringify(item) });
|
|
}
|
|
if (result.isError)
|
|
throw new Error(
|
|
content
|
|
.filter((item) => item.type === "text")
|
|
.map((item) => item.text)
|
|
.join("\n") || "SWADS_MCP_TOOL_FAILED",
|
|
);
|
|
return {
|
|
content,
|
|
details: {
|
|
source: "swads-mcp",
|
|
contextBounded: true,
|
|
maxRows,
|
|
maxTextChars,
|
|
},
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
const optionalAccount = {
|
|
external_account_id: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
tenant_id: Type.Optional(Type.String()),
|
|
};
|
|
|
|
export function createSwadsReadTools(): ToolDefinition[] {
|
|
return [
|
|
asTool(
|
|
"swads_whoami",
|
|
"Read the current SW Ads identity, visible accounts, capabilities, timezone, and currency.",
|
|
Type.Object({}, { additionalProperties: false }),
|
|
),
|
|
asTool(
|
|
"metrics_catalog",
|
|
"Read the supported SW Ads metrics, dimensions, and semantic-query schema before querying data.",
|
|
Type.Object({}, { additionalProperties: false }),
|
|
),
|
|
asTool(
|
|
"metrics_semantic_query",
|
|
"Run a read-only SW Ads semantic metrics query for the current or specified account.",
|
|
Type.Object(
|
|
{
|
|
...optionalAccount,
|
|
query: Type.Object({}, { additionalProperties: true }),
|
|
},
|
|
{ additionalProperties: false },
|
|
),
|
|
),
|
|
asTool(
|
|
"commerce_list_products",
|
|
"Read the TikTok Shop product catalog and current product metadata.",
|
|
Type.Object(
|
|
{
|
|
...optionalAccount,
|
|
authorized_business_center_id: Type.Optional(
|
|
Type.Union([Type.String(), Type.Null()]),
|
|
),
|
|
external_shop_id: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
only_selectable: Type.Optional(Type.Boolean()),
|
|
},
|
|
{ additionalProperties: false },
|
|
),
|
|
),
|
|
];
|
|
}
|