# MCP Learning: Array Parameter Serialization
## Discovery Date
2025-11-22
## The Problem
Orchestrator-tool's `pipeline` and `aggregate` actions were failing with error:
```
Pipeline requires "steps" array with {action, args} objects
```
Claude Desktop was sending the `steps` parameter, but it arrived as a JSON string instead of a parsed array.
## Root Cause
**Claude Desktop's MCP client serializes complex parameters (arrays, nested objects) as JSON strings in the wire protocol.**
### What We Observed
When Claude Desktop calls a tool with array parameters:
```javascript
// Schema definition (what we declare):
{
"steps": {
"type": "array",
"items": { "type": "object" }
}
}
// What Claude Desktop sends over MCP wire protocol:
{
"steps": "[\n {\"action\": \"create-resource\", \"args\": {...}}\n]"
// ↑ This is a STRING, not an array
}
// What the MCP SDK delivers to our server:
{
steps: '[\n {"action": "create-resource", "args": {...}}\n]'
// ↑ Still a string
}
```
### Why Other Tools Don't Hit This
- `data-tool`: Uses `data: {type: "object"}` → Objects serialize/deserialize naturally in JSON-RPC ✅
- `admin-tool`: Uses `name: {type: "string"}` → Strings pass through unchanged ✅
- `orchestrator-tool`: Uses `steps: {type: "array"}` → Arrays get stringified ⚠️
**Orchestrator-tool is the first tool in this codebase that accepts dynamic array parameters.**
## Why the Fix Belongs in the Tool (Not the MCP SDK)
### The Architectural Truth
The MCP SDK **cannot and should not** auto-parse arguments because:
1. **Protocol Agnosticism**: The SDK transports JSON-RPC messages. It doesn't validate against JSON Schemas.
2. **Semantic Ambiguity**: How does the SDK know whether a JSON string parameter should be:
- Parsed as structured data? (`"[{...}]"` → `[{...}]`)
- Kept as a literal string? (`"hello"` → `"hello"`)
Only the tool implementation knows the semantic meaning of each parameter.
3. **Correct Separation of Concerns**:
- **Client** (Claude Desktop): Validates user input against schema, serializes to JSON-RPC
- **Transport** (MCP SDK): Delivers messages faithfully without interpretation
- **Server** (Your tool): Deserializes based on semantic expectations
### The Schema's True Purpose
The JSON Schema you define:
```javascript
{
"steps": { "type": "array", "items": { "type": "object" } }
}
```
This schema is for **client-side UI validation**, not wire protocol deserialization. Claude Desktop uses it to:
- Validate user input before sending
- Generate UI hints
- Type-check arguments
But the wire protocol doesn't enforce this schema—it just sends JSON-RPC messages.
## The Correct Solution Pattern
**Every MCP tool that accepts complex types must parse JSON strings:**
```typescript
// Step 1: Accept the parameter (might be string or already parsed)
let steps = (context.args as any)?.steps;
// Step 2: Handle JSON string from Claude Desktop
if (typeof steps === 'string') {
try {
steps = JSON.parse(steps);
} catch (e) {
return {
success: false,
error: 'Parameter "steps" must be a valid JSON array',
};
}
}
// Step 3: Validate the parsed structure
if (!steps || !Array.isArray(steps)) {
return {
success: false,
error: 'Parameter "steps" must be an array',
};
}
// Step 4: Use the parsed array
for (const step of steps) {
// ... process step
}
```
## Additional Discovery: Format Normalization
Claude Desktop also sends steps in multiple formats:
**Format 1** (tool-prefixed action with params):
```json
{
"tool": "data-tool",
"action": "create-resource",
"params": { "name": "test" }
}
```
**Format 2** (slash-separated action):
```json
{
"action": "data-tool/create-resource",
"args": { "name": "test" }
}
```
**Format 3** (canonical format):
```json
{
"action": "create-resource",
"args": { "name": "test" }
}
```
### Format Normalization Pattern
```typescript
// Normalize step format to canonical {action, args}
let action: string;
let args: any;
if (step.tool && step.action) {
// Format 1: {tool, action, params}
action = step.action;
args = step.params || step.args || {};
} else if (step.action && step.action.includes('/')) {
// Format 2: {action: "tool/action", args}
const parts = step.action.split('/');
action = parts.length > 1 ? parts[1] : step.action;
args = step.args || {};
} else {
// Format 3: {action, args} - already correct
action = step.action;
args = step.args || {};
}
```
## Why Other MCP Servers Don't Show This
Most MCP servers avoid dynamic array parameters entirely, constraining users to simpler interfaces. They either:
1. Use fixed-structure objects instead of arrays
2. Require multiple separate tool calls instead of batch operations
3. Accept comma-separated strings instead of structured arrays
Orchestrator-tool needs compositional power (tool spawning tool), which requires dynamic array structures.
## The Constraint as Design
**This is not a bug—it's the sacred limit that makes MCP transport-agnostic.**
By keeping the SDK protocol-agnostic and putting parsing logic in tools, MCP can:
- Support multiple transport layers (stdio, HTTP, WebSocket)
- Remain independent of JSON Schema versions
- Let tools define their own serialization strategies
- Avoid coupling transport to validation
## References
- MCP Protocol Specification: https://modelcontextprotocol.io/docs/specification
- JSON-RPC 2.0 Specification: https://www.jsonrpc.org/specification
- orchestrator-tool implementation: `src/tools/orchestrator-tool.ts`
- MCP server handler: `src/index.ts` line 172
## Key Takeaway
**If your MCP tool accepts array or complex object parameters, you MUST parse JSON strings. This is not a workaround—it's the correct architectural pattern.**