import { getOnChainTools } from "@goat-sdk/adapter-model-context-protocol";
import { viem } from "@goat-sdk/wallet-viem";
import { isMainnet, walletClient } from "./wallet.js";
import z from "zod";
import { createSomniaDocsMcpClient } from "./client.js";
import { somniaConfig } from "./config.js";
import { REVISION, type SomniaTool } from "./types.js";
import {
generateMnemonic,
mnemonicToAccount,
privateKeyToAccount,
english,
} from "viem/accounts";
import {
keccak256,
toHex,
hexToBytes,
bytesToHex,
parseTransaction,
} from "viem";
export const toolsPromise = async (): Promise<
ReturnType<typeof getOnChainTools>
> => {
try {
const { listOfTools, toolHandler } = await getOnChainTools({
wallet: viem(walletClient),
plugins: [],
});
return {
listOfTools,
toolHandler,
};
} catch (error) {
console.error("Error initializing on-chain tools:", error);
throw error;
}
};
export const somniaTools: SomniaTool[] = [
{
name: "search_documentation",
title: "Search Somnia Documentation",
description:
"Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about Somnia Docs, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.",
inputSchema: {
query: z.string().describe("The search query string"),
},
callback: async ({ query }: { query: string }) => {
try {
const somniaDocsMcpClient = await createSomniaDocsMcpClient();
const response = await somniaDocsMcpClient.client.callTool({
name: "searchDocumentation",
arguments: { query },
});
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
} catch (err) {
const isAbort = (err as Error)?.name === "AbortError";
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: isAbort
? "Request timed out"
: "Failed to fetch account",
reason: String((err as Error)?.message ?? err),
},
null,
2,
),
},
],
};
}
},
},
// Thorest API
// Accounts
{
name: "get_account",
title: "Retrieve account details",
description:
"Get information about a Somnia account/contract by address. Optionally specify a revision (best | justified | finalized | block number | block ID).",
inputSchema: {
address: z
.string()
.regex(
somniaConfig.general.addressRegex,
"Invalid address: expected 20-byte hex, optional 0x prefix",
)
.describe(
"Account/contract address (20-byte hex, with or without 0x prefix)",
),
revision: z
.union([
z.enum([REVISION.Best, REVISION.Justified, REVISION.Finalized]),
z.number().int().nonnegative(),
z
.string()
.min(1)
.describe("Block ID (hex) or block number as string"),
])
.optional()
.describe(
"Revision: best | justified | finalized | block number | block ID (hex). If omitted, best is used.",
)
.default("best"),
},
callback: async ({
address,
revision,
}: {
address: string;
revision: z.ZodDefault<
z.ZodOptional<
z.ZodUnion<
[
z.ZodEnum<
[REVISION.Best, REVISION.Justified, REVISION.Finalized]
>,
z.ZodNumber,
z.ZodString,
]
>
>
>;
}) => {
const normalizedAddress = address.startsWith("0x")
? address.toLowerCase()
: `0x${address.toLowerCase()}`;
const base = isMainnet
? somniaConfig.mainnet.apiBaseUrl
: somniaConfig.testnet.apiBaseUrl;
const path = `/accounts/${encodeURIComponent(normalizedAddress)}`;
const qs = new URLSearchParams();
if (revision !== undefined && revision !== null) {
qs.set("revision", String(revision));
}
const url = `${base}${path}${qs.toString() ? `?${qs.toString()}` : ""}`;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
isMainnet
? somniaConfig.mainnet.controllerAbortTimeout
: somniaConfig.testnet.controllerAbortTimeout,
);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
const bodyText = await res.text().catch(() => "");
throw new Error(
`Somnia node responded ${res.status} ${res.statusText}${
bodyText ? `: ${bodyText}` : ""
}`,
);
}
const data = await res.json();
if (data == null) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
message: "Account not found (or revision not available)",
address: normalizedAddress,
revision: revision ?? "best",
},
null,
2,
),
},
],
};
}
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
} catch (err) {
const isAbort = (err as Error)?.name === "AbortError";
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: isAbort
? "Request timed out"
: "Failed to fetch account",
reason: String((err as Error)?.message ?? err),
url,
address: normalizedAddress,
revision: revision ?? "best",
},
null,
2,
),
},
],
};
} finally {
clearTimeout(timeout);
}
},
},
// Transactions
{
name: "get_transaction",
title: "Retrieve a transaction by ID",
description:
"Get a Somnia transaction by its ID. Optionally include pending txs (meta may be null), return raw hex, or pin to a specific head block.",
inputSchema: {
id: z
.string()
.regex(
somniaConfig.general.txidRegex,
"Invalid transaction ID: expected 0x + 64 hex chars",
)
.describe("Transaction ID (0x-prefixed 32-byte hex)"),
pending: z
.boolean()
.optional()
.describe(
"Include pending transactions (meta may be null). Default: false",
),
raw: z
.boolean()
.optional()
.describe("Include raw hex transaction in response. Default: false"),
head: z
.string()
.optional()
.describe("Head block ID to use; defaults to best if omitted"),
},
callback: async ({
id,
pending = false,
raw = false,
head,
}: {
id: string;
pending?: boolean;
raw?: boolean;
head?: string;
}) => {
const base = isMainnet
? somniaConfig.mainnet.apiBaseUrl
: somniaConfig.testnet.apiBaseUrl;
const path = `/transactions/${encodeURIComponent(id)}`;
const qs = new URLSearchParams();
if (typeof pending === "boolean") qs.set("pending", String(pending));
if (typeof raw === "boolean") qs.set("raw", String(raw));
if (head) qs.set("head", head);
const url = `${base}${path}${qs.toString() ? `?${qs.toString()}` : ""}`;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
isMainnet
? somniaConfig.mainnet.controllerAbortTimeout
: somniaConfig.testnet.controllerAbortTimeout,
);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
const bodyText = await res.text().catch(() => "");
throw new Error(
`Somnia node responded ${res.status} ${res.statusText}${
bodyText ? `: ${bodyText}` : ""
}`,
);
}
const data = await res.json();
if (data == null) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
message: "Transaction not found",
id,
pending,
raw,
head: head ?? "best",
},
null,
2,
),
},
],
};
}
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
} catch (err) {
const isAbort = (err as Error)?.name === "AbortError";
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: isAbort
? "Request timed out"
: "Failed to fetch transaction",
reason: String((err as Error)?.message ?? err),
url,
id,
pending,
raw,
head: head ?? "best",
},
null,
2,
),
},
],
};
} finally {
clearTimeout(timeout);
}
},
},
// Blocks
{
name: "get_block",
title: "Get a Somnia block",
description:
"Retrieve information about a Somnia block by its revision (block ID, number, or keywords: best | justified | finalized).",
inputSchema: {
revision: z
.union([
z.enum([REVISION.Best, REVISION.Justified, REVISION.Finalized]),
z.number().int().nonnegative(),
z
.string()
.min(1)
.describe("Block ID (hex) or block number as string"),
])
.describe(
"Block revision: hex ID, block number, or keywords: best | justified | finalized",
)
.default("best"),
expanded: z
.boolean()
.optional()
.describe(
"Return transactions expanded (objects) instead of just IDs (default: false)",
),
raw: z
.boolean()
.optional()
.describe(
"Return RLP-encoded block instead of structured JSON (default: false)",
),
},
callback: async ({
revision,
expanded = false,
raw = false,
}: {
revision: z.ZodDefault<
z.ZodUnion<
[
z.ZodEnum<[REVISION.Best, REVISION.Justified, REVISION.Finalized]>,
z.ZodNumber,
z.ZodString,
]
>
>;
expanded?: boolean;
raw?: boolean;
}) => {
const base = isMainnet
? somniaConfig.mainnet.apiBaseUrl
: somniaConfig.testnet.apiBaseUrl;
const path = `/blocks/${encodeURIComponent(String(revision))}`;
const qs = new URLSearchParams();
if (typeof expanded === "boolean") qs.set("expanded", String(expanded));
if (typeof raw === "boolean") qs.set("raw", String(raw));
const url = `${base}${path}${qs.toString() ? `?${qs.toString()}` : ""}`;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
isMainnet
? somniaConfig.mainnet.controllerAbortTimeout
: somniaConfig.testnet.controllerAbortTimeout,
);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
const bodyText = await res.text().catch(() => "");
throw new Error(
`Somnia node responded ${res.status} ${res.statusText}${
bodyText ? `: ${bodyText}` : ""
}`,
);
}
const data = await res.json();
if (data == null) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
message: "Block not found",
revision: String(revision),
expanded,
raw,
},
null,
2,
),
},
],
};
}
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
} catch (err) {
const isAbort = (err as Error)?.name === "AbortError";
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: isAbort
? "Request timed out"
: "Failed to fetch Somnia block",
reason: String((err as Error)?.message ?? err),
url,
},
null,
2,
),
},
],
};
} finally {
clearTimeout(timeout);
}
},
},
// Fees
{
name: "get_priority_fee",
title: "Suggest a priority fee",
description:
"Fetch a suggested priority fee for including a transaction in the next blocks from Somnia mainnet.",
inputSchema: {},
callback: async () => {
const base = isMainnet
? somniaConfig.mainnet.apiBaseUrl
: somniaConfig.testnet.apiBaseUrl;
const url = `${base}/fees/priority`;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
isMainnet
? somniaConfig.mainnet.controllerAbortTimeout
: somniaConfig.testnet.controllerAbortTimeout,
);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
const bodyText = await res.text().catch(() => "");
throw new Error(
`Somnia node responded ${res.status} ${res.statusText}${
bodyText ? `: ${bodyText}` : ""
}`,
);
}
const data = await res.json();
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
} catch (err) {
const isAbort = (err as Error)?.name === "AbortError";
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: isAbort
? "Request timed out"
: "Failed to fetch priority fee",
reason: String((err as Error)?.message ?? err),
url,
},
null,
2,
),
},
],
};
} finally {
clearTimeout(timeout);
}
},
},
// Wallet and signature management
{
name: "create_wallet",
title: "Create a Somnia wallet (mnemonic + keys)",
description:
"Generate a BIP-39 mnemonic (12/15/18/21/24 words) and derive the Ethereum-compatible account at standard derivation path m/44'/60'/0'/0/0. Returns mnemonic, private key, and public address.",
inputSchema: {
wordlistSize: z
.union([
z.literal(12),
z.literal(15),
z.literal(18),
z.literal(21),
z.literal(24),
])
.optional()
.describe("Length of the BIP-39 mnemonic wordlist. Default: 12"),
},
callback: async ({
wordlistSize = 12,
}: {
wordlistSize?: 12 | 15 | 18 | 21 | 24;
}) => {
try {
const strength =
wordlistSize === 12
? 128
: wordlistSize === 15
? 160
: wordlistSize === 18
? 192
: wordlistSize === 21
? 224
: 256;
const mnemonic = generateMnemonic(english, strength);
const account = mnemonicToAccount(mnemonic);
const result = {
mnemonic,
address: account.address,
};
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
} catch (err) {
const isAbort = (err as Error)?.name === "AbortError";
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: isAbort
? "Request timed out"
: "Failed to create wallet",
reason: String((err as Error)?.message ?? err),
},
null,
2,
),
},
],
};
}
},
},
{
name: "sign_certificate",
title: "Sign Certificate",
description:
"Create and sign a canonical certificate. Includes purpose, payload, domain, timestamp, nonce, and expiresAt.",
inputSchema: {
purpose: z
.enum(["identification", "attestation", "verification"])
.default("identification"),
payload: z.any().describe("Content to be attested (string or JSON)"),
domain: z.string().min(1).describe("Scope or domain where it is valid"),
timestamp: z.number().int().positive().optional(),
},
callback: async ({
purpose,
payload,
domain,
timestamp = Math.floor(Date.now() / 1000),
}: {
purpose: "identification" | "attestation" | "verification";
payload: any;
domain: string;
timestamp?: number;
}) => {
const secretKey = process.env.AGENT_SECRET_KEY;
if (!secretKey) {
throw new Error("Missing AGENT_SECRET_KEY variable to use this tool.");
}
const account = privateKeyToAccount(secretKey as `0x${string}`);
const certificateData = {
purpose,
payload,
timestamp,
domain,
signer: account.address,
};
const message = JSON.stringify(certificateData);
const signature = await account.signMessage({
message,
});
return {
content: [
{
type: "text",
text: JSON.stringify(
{
certificate: certificateData,
signature,
},
null,
2,
),
},
],
};
},
},
{
name: "sign_raw_transaction",
title: "Sign Raw Transaction",
description: "Sign an Ethereum raw transaction string.",
inputSchema: {
rawTransaction: z.string(),
},
callback: async ({ rawTransaction }: { rawTransaction: string }) => {
const secretKey = process.env.AGENT_SECRET_KEY;
if (!secretKey) {
throw new Error("Missing AGENT_SECRET_KEY variable to use this tool.");
}
const account = privateKeyToAccount(secretKey as `0x${string}`);
// Parse the raw transaction
const parsedTx = parseTransaction(rawTransaction as `0x${string}`);
// Sign the transaction
const signedTx = await account.signTransaction(parsedTx as any);
return {
content: [
{
type: "text",
text: JSON.stringify(
{
signedTransaction: signedTx,
from: account.address,
},
null,
2,
),
},
],
};
},
},
];