Skip to content

Commit e1eb84a

Browse files
authored
add deployContract tool (#253)
* add deployContract tool * remove extra readme text * fix typo
1 parent 8ce9308 commit e1eb84a

5 files changed

Lines changed: 326 additions & 9 deletions

File tree

.changeset/shaky-icons-speak.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@sei-js/mcp-server": minor
3+
---
4+
5+
Add deployContract tool

packages/mcp-server/README.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -364,15 +364,16 @@ The server provides the following MCP tools for agents.
364364

365365
#### Blockchain services
366366

367-
| Tool Name | Description | Key Parameters |
368-
|-------------------|-------------|----------------|
369-
| `get-chain-info` | Get network information | `network` |
370-
| `get-balance` | Get native token balance | `address` (address), `network` |
371-
| `transfer-sei` | Send native tokens | `to` (address), `amount`, `network` |
372-
| `get-transaction` | Get transaction details | `txHash`, `network` |
373-
| `read-contract` | Read smart contract state | `contractAddress` (address), `abi`, `functionName`, `args` (optional), `network` |
374-
| `write-contract` | Write to smart contract | `contractAddress` (address), `abi`, `functionName`, `args` (optional), `network` |
375-
| `is-contract` | Check if address is a contract | `address` (address), `network` |
367+
| Tool Name | Description | Key Parameters |
368+
|-------------------|--------------------------------|---------------------------------------------------------|
369+
| `get-chain-info` | Get network information | `network` |
370+
| `get-balance` | Get native token balance | `address` (address), `network` |
371+
| `transfer-sei` | Send native tokens | `to` (address), `amount`, `network` |
372+
| `get-transaction` | Get transaction details | `txHash`, `network` |
373+
| `read-contract` | Read smart contract state | `contractAddress` (address), `abi`, `functionName`, `args` (optional), `network` |
374+
| `deploy-contract` | Deploy smart contract | `bytecode`, `abi`, `args`, `network` |
375+
| `write-contract` | Write to smart contract | `contractAddress` (address), `abi`, `functionName`, `args` (optional), `network` |
376+
| `is-contract` | Check if address is a contract | `address` (address), `network` |
376377

377378
### Resources
378379

packages/mcp-server/src/core/services/contracts.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,54 @@ export async function isContract(address: string, network = DEFAULT_NETWORK): Pr
5252
const code = await client.getBytecode({ address: validatedAddress });
5353
return code !== undefined && code !== '0x';
5454
}
55+
56+
/**
57+
* Deploy a new contract
58+
* @param bytecode Contract bytecode as hex string
59+
* @param abi Contract ABI for constructor
60+
* @param args Constructor arguments (optional)
61+
* @param network Network name or chain ID
62+
* @returns Object with contract address and transaction hash
63+
* @throws Error if no private key is available or deployment fails
64+
*/
65+
export async function deployContract(
66+
bytecode: Hex,
67+
abi: any[],
68+
args?: any[],
69+
network = DEFAULT_NETWORK
70+
): Promise<{ address: Hash; transactionHash: Hash }> {
71+
// Get private key from environment
72+
const key = getPrivateKeyAsHex();
73+
74+
if (!key) {
75+
throw new Error('Private key not available. Set the PRIVATE_KEY environment variable and restart the MCP server.');
76+
}
77+
78+
const client = getWalletClient(key, network);
79+
80+
if (!client.account) {
81+
throw new Error('Wallet client account not available for contract deployment.');
82+
}
83+
84+
// Deploy the contract
85+
const hash = await client.deployContract({
86+
abi,
87+
bytecode,
88+
args: args || [],
89+
account: client.account,
90+
chain: client.chain,
91+
});
92+
93+
// Wait for the transaction to be mined and get the contract address
94+
const publicClient = getPublicClient(network);
95+
const receipt = await publicClient.waitForTransactionReceipt({ hash });
96+
97+
if (!receipt.contractAddress) {
98+
throw new Error('Contract deployment failed - no contract address returned');
99+
}
100+
101+
return {
102+
address: receipt.contractAddress,
103+
transactionHash: hash,
104+
};
105+
}

packages/mcp-server/src/core/tools.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,58 @@ export function registerEVMTools(server: McpServer) {
836836
}
837837
);
838838

839+
// Deploy contract
840+
server.tool(
841+
'deploy_contract',
842+
'Deploy a new smart contract to the blockchain. This creates a new contract instance and returns both the deployment transaction hash and the deployed contract address.',
843+
{
844+
bytecode: z.string().describe("The compiled contract bytecode as a hex string (e.g., '0x608060405234801561001057600080fd5b50...')"),
845+
abi: z.array(z.any()).describe('The contract ABI (Application Binary Interface) as a JSON array, needed for constructor function'),
846+
args: z.array(z.any()).optional().describe("The constructor arguments to pass during deployment, as an array (e.g., ['param1', 'param2']). Leave empty if constructor has no parameters."),
847+
network: z.string().optional().describe("Network name (e.g., 'sei', 'sei-testnet', 'sei-devnet') or chain ID. Defaults to Sei mainnet.")
848+
},
849+
async ({ bytecode, abi, args = [], network = DEFAULT_NETWORK }) => {
850+
try {
851+
// Parse ABI if it's a string
852+
const parsedAbi = typeof abi === 'string' ? JSON.parse(abi) : abi;
853+
854+
// Ensure bytecode is a proper hex string
855+
const formattedBytecode = bytecode.startsWith('0x') ? bytecode as Hex : `0x${bytecode}` as Hex;
856+
857+
const result = await services.deployContract(formattedBytecode, parsedAbi, args, network);
858+
859+
return {
860+
content: [
861+
{
862+
type: 'text',
863+
text: JSON.stringify(
864+
{
865+
success: true,
866+
network,
867+
contractAddress: result.address,
868+
transactionHash: result.transactionHash,
869+
message: 'Contract deployed successfully'
870+
},
871+
null,
872+
2
873+
)
874+
}
875+
]
876+
};
877+
} catch (error) {
878+
return {
879+
content: [
880+
{
881+
type: 'text',
882+
text: `Error deploying contract: ${error instanceof Error ? error.message : String(error)}`
883+
}
884+
],
885+
isError: true
886+
};
887+
}
888+
}
889+
);
890+
839891
// Check if address is a contract
840892
server.tool(
841893
'is_contract',

packages/mcp-server/src/tests/core/tools.test.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,7 @@ describe('EVM Tools', () => {
844844
// Verify contract interaction tools
845845
expect(registeredTools.has('read_contract')).toBe(true);
846846
expect(registeredTools.has('write_contract')).toBe(true);
847+
expect(registeredTools.has('deploy_contract')).toBe(true);
847848
});
848849

849850
// Group 4: Transaction Tools
@@ -2501,4 +2502,211 @@ describe('EVM Tools', () => {
25012502
verifyErrorResponse(response, 'Error checking if address is a contract: Test error');
25022503
});
25032504
});
2505+
2506+
test('write_contract - error with non-Error object', async () => {
2507+
const tool = checkToolExists('write_contract');
2508+
if (!tool) return;
2509+
2510+
const params = {
2511+
contractAddress: '0x1234567890123456789012345678901234567890',
2512+
abi: [
2513+
{
2514+
inputs: [{ name: 'to', type: 'address' }, { name: 'value', type: 'uint256' }],
2515+
name: 'transfer',
2516+
outputs: [{ name: '', type: 'bool' }],
2517+
stateMutability: 'nonpayable',
2518+
type: 'function'
2519+
}
2520+
],
2521+
functionName: 'transfer',
2522+
args: ['0x0987654321098765432109876543210987654321', '1000000000000000000'],
2523+
network: mockNetwork
2524+
};
2525+
2526+
const nonErrorObject = "This is a string error";
2527+
2528+
(services.writeContract as jest.Mock).mockImplementationOnce(() => {
2529+
throw nonErrorObject;
2530+
});
2531+
2532+
const response = await tool.handler(params);
2533+
2534+
expect(response).toHaveProperty('isError', true);
2535+
expect(response.content[0].text).toContain('Error writing to contract: This is a string error');
2536+
});
2537+
2538+
test('deploy_contract - success path', async () => {
2539+
const tool = checkToolExists('deploy_contract');
2540+
if (!tool) return;
2541+
2542+
const mockDeployResult = {
2543+
address: '0x1234567890123456789012345678901234567890' as `0x${string}`,
2544+
transactionHash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as `0x${string}`
2545+
};
2546+
2547+
// Mock the deployContract function before calling testToolSuccess
2548+
(services.deployContract as jest.Mock).mockImplementationOnce(() => {
2549+
return Promise.resolve(mockDeployResult);
2550+
});
2551+
2552+
const params = {
2553+
bytecode: '0x608060405234801561001057600080fd5b50',
2554+
abi: [
2555+
{
2556+
inputs: [{ name: 'initialValue', type: 'uint256' }],
2557+
stateMutability: 'nonpayable',
2558+
type: 'constructor'
2559+
}
2560+
],
2561+
args: ['1000'],
2562+
network: mockNetwork
2563+
};
2564+
2565+
const response = await testToolSuccess(tool, params);
2566+
2567+
expect(services.deployContract).toHaveBeenCalledWith(
2568+
'0x608060405234801561001057600080fd5b50',
2569+
params.abi,
2570+
params.args,
2571+
mockNetwork
2572+
);
2573+
2574+
expect(response).toHaveProperty('content');
2575+
expect(response.content[0]).toHaveProperty('type', 'text');
2576+
2577+
const responseData = JSON.parse(response.content[0].text);
2578+
expect(responseData).toMatchObject({
2579+
success: true,
2580+
network: mockNetwork,
2581+
contractAddress: mockDeployResult.address,
2582+
transactionHash: mockDeployResult.transactionHash,
2583+
message: 'Contract deployed successfully'
2584+
});
2585+
});
2586+
2587+
test('deploy_contract - success path without 0x prefix in bytecode', async () => {
2588+
const tool = checkToolExists('deploy_contract');
2589+
if (!tool) return;
2590+
2591+
const mockDeployResult = {
2592+
address: '0x1234567890123456789012345678901234567890' as `0x${string}`,
2593+
transactionHash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as `0x${string}`
2594+
};
2595+
2596+
// Mock the deployContract function before calling testToolSuccess
2597+
(services.deployContract as jest.Mock).mockImplementationOnce(() => {
2598+
return Promise.resolve(mockDeployResult);
2599+
});
2600+
2601+
const params = {
2602+
bytecode: '608060405234801561001057600080fd5b50', // Without 0x prefix
2603+
abi: [
2604+
{
2605+
inputs: [],
2606+
stateMutability: 'nonpayable',
2607+
type: 'constructor'
2608+
}
2609+
],
2610+
network: mockNetwork
2611+
};
2612+
2613+
const response = await testToolSuccess(tool, params);
2614+
2615+
expect(services.deployContract).toHaveBeenCalledWith(
2616+
'0x608060405234801561001057600080fd5b50', // Should be formatted with 0x
2617+
params.abi,
2618+
[],
2619+
mockNetwork
2620+
);
2621+
2622+
expect(response).toHaveProperty('content');
2623+
expect(response.content[0]).toHaveProperty('type', 'text');
2624+
});
2625+
2626+
test('deploy_contract - success path with default network and no args', async () => {
2627+
const tool = checkToolExists('deploy_contract');
2628+
if (!tool) return;
2629+
2630+
const mockDeployResult = {
2631+
address: '0x1234567890123456789012345678901234567890' as `0x${string}`,
2632+
transactionHash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as `0x${string}`
2633+
};
2634+
2635+
// Mock the deployContract function before calling testToolSuccess
2636+
(services.deployContract as jest.Mock).mockImplementationOnce(() => {
2637+
return Promise.resolve(mockDeployResult);
2638+
});
2639+
2640+
const params = {
2641+
bytecode: '0x608060405234801561001057600080fd5b50',
2642+
abi: [
2643+
{
2644+
inputs: [],
2645+
stateMutability: 'nonpayable',
2646+
type: 'constructor'
2647+
}
2648+
]
2649+
};
2650+
2651+
const response = await testToolSuccess(tool, params);
2652+
2653+
expect(services.deployContract).toHaveBeenCalledWith(
2654+
params.bytecode,
2655+
params.abi,
2656+
[],
2657+
'sei' // DEFAULT_NETWORK
2658+
);
2659+
2660+
expect(response).toHaveProperty('content');
2661+
expect(response.content[0]).toHaveProperty('type', 'text');
2662+
});
2663+
2664+
test('deploy_contract - error path', async () => {
2665+
const tool = checkToolExists('deploy_contract');
2666+
if (!tool) return;
2667+
2668+
const params = {
2669+
bytecode: '0x608060405234801561001057600080fd5b50',
2670+
abi: [
2671+
{
2672+
inputs: [],
2673+
stateMutability: 'nonpayable',
2674+
type: 'constructor'
2675+
}
2676+
],
2677+
network: mockNetwork
2678+
};
2679+
2680+
const response = await testToolError(tool, params, services.deployContract as jest.Mock, mockError);
2681+
2682+
verifyErrorResponse(response, 'Error deploying contract: Test error');
2683+
});
2684+
2685+
test('deploy_contract - error with non-Error object', async () => {
2686+
const tool = checkToolExists('deploy_contract');
2687+
if (!tool) return;
2688+
2689+
const params = {
2690+
bytecode: '0x608060405234801561001057600080fd5b50',
2691+
abi: [
2692+
{
2693+
inputs: [],
2694+
stateMutability: 'nonpayable',
2695+
type: 'constructor'
2696+
}
2697+
],
2698+
network: mockNetwork
2699+
};
2700+
2701+
const nonErrorObject = "This is a string error";
2702+
2703+
(services.deployContract as jest.Mock).mockImplementationOnce(() => {
2704+
throw nonErrorObject;
2705+
});
2706+
2707+
const response = await tool.handler(params);
2708+
2709+
expect(response).toHaveProperty('isError', true);
2710+
expect(response.content[0].text).toContain('Error deploying contract: This is a string error');
2711+
});
25042712
});

0 commit comments

Comments
 (0)