Skip to content

Commit d5d31aa

Browse files
refactor: clean v1 tool operations and harden api params
1 parent ae300b6 commit d5d31aa

10 files changed

Lines changed: 172 additions & 62 deletions

File tree

.env.example

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,2 @@
1-
# Dynadot API Configuration
2-
# Get your API key at: https://www.dynadot.com/help/question/api-key
3-
DYNADOT_API_KEY=your-api-key-here
4-
5-
# Optional: Sandbox testing (https://www.dynadot.com/help/question/api-sandbox)
6-
# Generate sandbox key in Dynadot account > Tools > API > API Sandbox Key
7-
DYNADOT_SANDBOX=true
8-
DYNADOT_SANDBOX_KEY=your-sandbox-key-here
9-
10-
# Test domain (replace with an actual domain you own)
11-
TEST_DOMAIN=example.com
12-
13-
# Target username for domain push operations (another Dynadot account)
14-
DYNADOT_TARGET_USERNAME=testuser
15-
16-
# Contact ID for WHOIS operations (get from: mcp__dynadot__dynadot_contact({ action: "list" }))
17-
TEST_CONTACT_ID=1234567
18-
19-
# Folder ID for folder operations (use -1 for root folder)
20-
TEST_FOLDER_ID=-1
1+
DYNADOT_API_KEY=your_dynadot_api_key_here
2+
DYNADOT_SANDBOX=false

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ A Domain MCP server that brings natural language domain management to Claude, Cu
3939
## Features
4040

4141
- **🤖 Natural Language Domain Management**: Use AI assistants like Claude and Cursor to manage domains conversationally - no API knowledge needed
42-
- **📦 Complete Domain MCP Server**: 106 Dynadot API actions across 10 composite MCP tools for domains, DNS, transfers, and more
42+
- **📦 Complete Domain MCP Server**: 108 Dynadot API actions across 10 composite MCP tools for domains, DNS, transfers, and more
4343
- **⚡ Production-Ready AI Integration**: Built for Claude Code, Cursor, Claude Desktop, and any MCP-compatible client
4444
- **🔒 Type-Safe & Reliable**: TypeScript, Zod validation, comprehensive test suite with 100% tool coverage
4545
- **✅ MCP Protocol Compliant**: Full Model Context Protocol specification compliance with real CRUD operation tests
@@ -364,7 +364,7 @@ npm test
364364
# Integration tests (requires network access + Dynadot credentials)
365365
RUN_INTEGRATION_TESTS=true DYNADOT_API_KEY=your-api-key TEST_DOMAIN=your-domain.com npm test
366366

367-
# E2E tests (validates all 106 API endpoints)
367+
# E2E tests (validates all 108 API endpoints)
368368
RUN_INTEGRATION_TESTS=true TEST_DOMAIN=your-domain.com npm test -- test/e2e.test.ts
369369

370370
# Functional tests (real CRUD operations)
@@ -383,7 +383,7 @@ npm run dev
383383
## Testing
384384

385385
### E2E Tests
386-
Validates that all 106 API actions are properly mapped and return valid responses.
386+
Validates that all 108 API actions are properly mapped and return valid responses.
387387

388388
### Functional Tests
389389
Tests real CRUD operations:
@@ -427,10 +427,10 @@ Unlocking domains may need to be done through the Dynadot control panel.
427427

428428
```
429429
src/
430-
├── index.ts # MCP server entry point
431-
├── client.ts # Dynadot API client
432-
├── schema.ts # Tool and action definitions (10 tools, 106 actions)
433-
└── register.ts # Tool registration with MCP server
430+
├── index.ts # MCP server entry point
431+
├── client.ts # Dynadot API client
432+
├── register.ts # Tool registration with MCP server
433+
└── schemas/*.ts # Tool and operation definitions (10 tools, 108 actions)
434434
435435
test/
436436
├── e2e.test.ts # Endpoint validation tests

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "domain-mcp",
33
"version": "1.1.1",
4-
"description": "Domain MCP - AI-powered domain management for Dynadot API. Natural language domain operations via Claude, Cursor, and MCP clients. 106 commands: domain registration, DNS, transfers, WHOIS contacts, aftermarket.",
4+
"description": "Domain MCP - AI-powered domain management for Dynadot API. Natural language domain operations via Claude, Cursor, and MCP clients. 108 commands: domain registration, DNS, transfers, WHOIS contacts, aftermarket.",
55
"type": "module",
66
"main": "dist/index.js",
77
"types": "dist/index.d.ts",

src/client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import ky, { type KyInstance } from 'ky';
22

3+
const RESERVED_PARAM_KEYS = new Set(['key', 'command']);
4+
35
/**
46
* Parameters passed to Dynadot API commands.
57
* Values can be string, number, boolean, or undefined (undefined values are filtered out).
@@ -123,6 +125,9 @@ export class DynadotClient {
123125
searchParams.set('command', command);
124126

125127
for (const [key, value] of Object.entries(params)) {
128+
if (RESERVED_PARAM_KEYS.has(key)) {
129+
throw new Error(`Reserved parameter "${key}" cannot be set by tool input`);
130+
}
126131
if (value !== undefined) {
127132
searchParams.set(key, String(value));
128133
}

src/prompts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@ export function registerAllPrompts(server: McpServer): void {
4141
type: 'text',
4242
text: `Help me configure DNS for ${args.domain}:
4343
44-
1. First, get current DNS records using dynadot_dns with action: get
44+
1. First, get current DNS records using dynadot_dns with operation: get
4545
2. Ask me what I want to set up:
4646
- Website hosting (A/AAAA records)
4747
- Email (MX records)
4848
- Domain verification (TXT records)
4949
- Subdomain (CNAME records)
5050
3. Guide me through the configuration
51-
4. Apply changes using dynadot_dns with action: set
51+
4. Apply changes using dynadot_dns with operation: set
5252
5. Verify the changes were applied`,
5353
},
5454
},

src/register.ts

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,19 @@ import {
1212

1313
export function registerAllTools(server: McpServer): void {
1414
for (const tool of compositeTools) {
15-
// Build action enum from keys
16-
const actionKeys = Object.keys(tool.actions) as [string, ...string[]];
15+
// Build operation enum from keys
16+
const operationKeys = Object.keys(tool.actions) as [string, ...string[]];
1717

18-
// Build combined input schema with action + all possible params
19-
const actionDescriptions = actionKeys
18+
// Build combined input schema with operation + all possible params
19+
const operationDescriptions = operationKeys
2020
.map((k) => {
2121
const actionDef = tool.actions[k];
2222
return actionDef ? `${k}: ${actionDef.description}` : k;
2323
})
2424
.join(' | ');
2525

2626
const inputSchema: Record<string, z.ZodTypeAny> = {
27-
action: z.enum(actionKeys).describe(`Action to perform: ${actionDescriptions}`),
27+
operation: z.enum(operationKeys).describe(`Operation to perform: ${operationDescriptions}`),
2828
};
2929

3030
// Collect all unique params across actions
@@ -47,14 +47,14 @@ export function registerAllTools(server: McpServer): void {
4747
inputSchema,
4848
},
4949
async (input) => {
50-
const action = input.action as string;
51-
const actionDef = tool.actions[action];
50+
const operation = input.operation as string;
51+
const actionDef = tool.actions[operation];
5252

5353
if (!actionDef) {
54-
const error = createToolError(`Unknown action: ${action}`, {
54+
const error = createToolError(`Unknown operation: ${operation}`, {
5555
type: 'UNKNOWN_ACTION',
56-
action,
57-
validActions: actionKeys,
56+
action: operation,
57+
validActions: operationKeys,
5858
tool: tool.name,
5959
});
6060
return {
@@ -63,19 +63,48 @@ export function registerAllTools(server: McpServer): void {
6363
};
6464
}
6565

66-
const client = getClient();
67-
const params = actionDef.transform
68-
? actionDef.transform(action, input as Record<string, unknown>)
69-
: (input as ApiParams);
66+
const parsedInput = actionDef.params
67+
? actionDef.params.safeParse(input)
68+
: ({ success: true, data: {} } as const);
7069

71-
// Remove 'action' from params sent to API
72-
delete params.action;
70+
if (!parsedInput.success) {
71+
const details = parsedInput.error.issues
72+
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
73+
.join('; ');
7374

74-
const result = await client.execute(actionDef.command, params);
75-
const normalized = normalizeResponse(actionDef.command, result);
76-
return {
77-
content: [{ type: 'text', text: JSON.stringify(normalized, null, 2) }],
78-
};
75+
const error = createToolError('Invalid tool input', {
76+
type: 'VALIDATION_ERROR',
77+
tool: tool.name,
78+
message: details,
79+
});
80+
return {
81+
content: [{ type: 'text', text: error.toJSON() }],
82+
isError: true,
83+
};
84+
}
85+
86+
try {
87+
const client = getClient();
88+
const params = actionDef.transform
89+
? actionDef.transform(operation, parsedInput.data as Record<string, unknown>)
90+
: (parsedInput.data as Record<string, unknown> as ApiParams);
91+
92+
const result = await client.execute(actionDef.command, params);
93+
const normalized = normalizeResponse(actionDef.command, result);
94+
return {
95+
content: [{ type: 'text', text: JSON.stringify(normalized, null, 2) }],
96+
};
97+
} catch (error) {
98+
const toolError = createToolError('Tool execution failed', {
99+
type: 'API_ERROR',
100+
tool: tool.name,
101+
message: error instanceof Error ? error.message : String(error),
102+
});
103+
return {
104+
content: [{ type: 'text', text: toolError.toJSON() }],
105+
isError: true,
106+
};
107+
}
79108
},
80109
);
81110
}

src/tools/generate-ideas.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ interface AvailableDomain {
1616
price?: string;
1717
}
1818

19+
function shuffle<T>(items: T[]): T[] {
20+
const shuffled = [...items];
21+
for (let i = shuffled.length - 1; i > 0; i--) {
22+
const j = Math.floor(Math.random() * (i + 1));
23+
[shuffled[i], shuffled[j]] = [shuffled[j] as T, shuffled[i] as T];
24+
}
25+
return shuffled;
26+
}
27+
1928
function generateExact(keywords: string[], tlds: string[]): string[] {
2029
const results: string[] = [];
2130
for (const keyword of keywords) {
@@ -155,7 +164,9 @@ export function registerGenerateIdeasTool(server: McpServer): void {
155164
const maxToCheck = (input.maxToCheck as number) ?? 100;
156165

157166
// Generate exact matches first (always checked), then other patterns
158-
const exactDomains = patterns.includes('exact') ? generateExact(keywords, tlds) : [];
167+
const exactDomains = patterns.includes('exact')
168+
? [...new Set(generateExact(keywords, tlds))]
169+
: [];
159170
const otherDomains = new Set<string>();
160171
for (const pattern of patterns) {
161172
if (pattern === 'exact') continue;
@@ -168,11 +179,10 @@ export function registerGenerateIdeasTool(server: McpServer): void {
168179
}
169180

170181
// Exact matches always included, fill remaining capacity with shuffled others
171-
const shuffledOthers = [...otherDomains].sort(() => Math.random() - 0.5);
172-
const toCheck = [
173-
...exactDomains,
174-
...shuffledOthers.slice(0, maxToCheck - exactDomains.length),
175-
];
182+
const shuffledOthers = shuffle([...otherDomains]);
183+
const limitedExactDomains = exactDomains.slice(0, maxToCheck);
184+
const remainingSlots = Math.max(0, maxToCheck - limitedExactDomains.length);
185+
const toCheck = [...limitedExactDomains, ...shuffledOthers.slice(0, remainingSlots)];
176186

177187
// Check availability in batches
178188
const available: AvailableDomain[] = [];

src/tools/help.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export function registerHelpTool(server: McpServer): void {
1313
'dynadot_help',
1414
{
1515
description:
16-
'Discover available tools and actions. Use query: "tools" to list all tools, "actions" with a tool name to list actions, "examples" for usage examples.',
16+
'Discover available tools and operations. Use query: "tools" to list all tools, "actions" with a tool name to list operations, "examples" for usage examples.',
1717
inputSchema,
1818
},
1919
async (input) => {
@@ -37,6 +37,10 @@ export function registerHelpTool(server: McpServer): void {
3737
tools,
3838
standalone: [
3939
{ name: 'check_domain', description: 'Check single domain availability' },
40+
{
41+
name: 'generate_domain_ideas',
42+
description: 'Generate available domain ideas from keywords',
43+
},
4044
{ name: 'dynadot_help', description: 'This help tool' },
4145
],
4246
},
@@ -102,7 +106,7 @@ export function registerHelpTool(server: McpServer): void {
102106
{
103107
description: 'List all domains',
104108
tool: 'dynadot_domain',
105-
input: { action: 'list' },
109+
input: { operation: 'list' },
106110
},
107111
{
108112
description: 'Check domain availability',
@@ -112,7 +116,7 @@ export function registerHelpTool(server: McpServer): void {
112116
{
113117
description: 'Get domain DNS records',
114118
tool: 'dynadot_dns',
115-
input: { action: 'get', domain: 'example.com' },
119+
input: { operation: 'get', domain: 'example.com' },
116120
},
117121
],
118122
},

test/client.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const getSpy = vi.fn();
4+
5+
vi.mock('ky', () => ({
6+
default: {
7+
create: vi.fn(() => ({
8+
get: getSpy,
9+
})),
10+
},
11+
}));
12+
13+
import { DynadotClient } from '../src/client.js';
14+
15+
describe('DynadotClient', () => {
16+
beforeEach(() => {
17+
getSpy.mockReset();
18+
getSpy.mockReturnValue({
19+
json: vi.fn().mockResolvedValue({ Status: 'success' }),
20+
});
21+
});
22+
23+
it('should reject reserved command parameter override', async () => {
24+
const client = new DynadotClient({ apiKey: 'test-key' });
25+
26+
await expect(client.execute('domain_info', { command: 'delete' })).rejects.toThrow(
27+
'Reserved parameter "command" cannot be set by tool input',
28+
);
29+
});
30+
31+
it('should preserve command and key when building search params', async () => {
32+
const client = new DynadotClient({ apiKey: 'test-key' });
33+
await client.execute('domain_info', { domain: 'example.com' });
34+
35+
const options = getSpy.mock.calls[0]?.[1] as { searchParams: URLSearchParams };
36+
expect(options.searchParams.get('command')).toBe('domain_info');
37+
expect(options.searchParams.get('key')).toBe('test-key');
38+
expect(options.searchParams.get('domain')).toBe('example.com');
39+
});
40+
});

test/register.test.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ import { getClient } from '../src/client.js';
1515
import { normalizeResponse } from '../src/normalize.js';
1616
import { registerAllTools } from '../src/register.js';
1717

18+
type RegisteredTool = {
19+
handler: (input: Record<string, unknown>) => Promise<Record<string, unknown>>;
20+
};
21+
22+
function getRegisteredTools(server: McpServer): Record<string, RegisteredTool> {
23+
return (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
24+
._registeredTools;
25+
}
26+
1827
describe('Tool Registration with Normalizer', () => {
1928
let server: McpServer;
2029
let mockClient: { execute: ReturnType<typeof vi.fn> };
@@ -33,8 +42,39 @@ describe('Tool Registration with Normalizer', () => {
3342

3443
it('should call normalizeResponse for tool results', async () => {
3544
registerAllTools(server);
45+
const tools = getRegisteredTools(server);
46+
const domainTool = tools.dynadot_domain;
47+
48+
await domainTool.handler({ operation: 'list' });
49+
50+
expect(normalizeResponse).toHaveBeenCalled();
51+
});
52+
53+
it('should require operation-specific params before executing', async () => {
54+
registerAllTools(server);
55+
const tools = getRegisteredTools(server);
56+
const domainTool = tools.dynadot_domain;
57+
58+
const result = await domainTool.handler({ operation: 'register' });
59+
60+
expect(result.isError).toBe(true);
61+
expect(mockClient.execute).not.toHaveBeenCalled();
62+
});
63+
64+
it('should preserve api action parameter for operations that need it', async () => {
65+
registerAllTools(server);
66+
const tools = getRegisteredTools(server);
67+
const transferTool = tools.dynadot_transfer;
68+
69+
await transferTool.handler({
70+
operation: 'set_push_request',
71+
domain: 'example.com',
72+
action: 'decline',
73+
});
3674

37-
// The normalizer should be imported and used
38-
expect(normalizeResponse).toBeDefined();
75+
expect(mockClient.execute).toHaveBeenCalledWith('set_domain_push_request', {
76+
domain: 'example.com',
77+
action: 'decline',
78+
});
3979
});
4080
});

0 commit comments

Comments
 (0)