Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/readme-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Documentation update for scope3 package installation
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,27 @@ const campaign = await client.campaigns.create({

## CLI Usage

The CLI dynamically discovers available commands from the API server, ensuring it's always up-to-date:
The CLI dynamically discovers available commands from the API server, ensuring it's always up-to-date.

### Quick Start (Recommended)

For the shortest command, use the `scope3` package:

```bash
# Use with npx (no install needed)
npx scope3 --help

# Or install globally
npm install -g scope3
scope3 --help
```

### Alternative: Full Package Name

You can also use the full package name:

```bash
# Install globally or use npx
npm install -g @scope3/agentic-client
# or
npx @scope3/agentic-client --help

# Configure authentication
Expand Down
74 changes: 44 additions & 30 deletions src/__tests__/client-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,27 @@ describe('Scope3Client MCP Protocol', () => {
});

describe('structuredContent handling (preferred path)', () => {
it('should return structuredContent when present', async () => {
it('should return structuredContent with message when text content present', async () => {
const expectedData = { id: '123', name: 'Test Campaign', status: 'active' };
const textMessage = 'Campaign retrieved successfully';
mockMcpClient.callTool.mockResolvedValue({
structuredContent: expectedData,
content: [{ type: 'text', text: 'This should be ignored' }],
content: [{ type: 'text', text: textMessage }],
});

const result = await client['callTool']<Record<string, unknown>, typeof expectedData>(
'campaigns_get',
{ campaignId: '123' }
);

expect(result).toEqual({ _message: textMessage, ...expectedData });
});

it('should return structuredContent without message when no text content', async () => {
const expectedData = { id: '123', name: 'Test Campaign', status: 'active' };
mockMcpClient.callTool.mockResolvedValue({
structuredContent: expectedData,
content: [],
});

const result = await client['callTool']<Record<string, unknown>, typeof expectedData>(
Expand Down Expand Up @@ -149,52 +165,50 @@ describe('Scope3Client MCP Protocol', () => {
});
});

describe('text content fallback (JSON parsing)', () => {
it('should parse valid JSON from text content', async () => {
describe('API specification violations', () => {
it('should throw error when JSON is in text content instead of structuredContent', async () => {
const data = { id: '456', status: 'completed' };
mockMcpClient.callTool.mockResolvedValue({
content: [{ type: 'text', text: JSON.stringify(data) }],
});

const result = await client['callTool']('test_tool', {});

expect(result).toEqual(data);
await expect(client['callTool']('test_tool', {})).rejects.toThrow(
'MCP API returned response without structuredContent'
);
await expect(client['callTool']('test_tool', {})).rejects.toThrow(
'This violates the Scope3 API specification'
);
});

it('should parse JSON array from text content', async () => {
it('should throw error when array is in text content instead of structuredContent', async () => {
const data = [{ id: '1' }, { id: '2' }];
mockMcpClient.callTool.mockResolvedValue({
content: [{ type: 'text', text: JSON.stringify(data) }],
});

const result = await client['callTool']('test_tool', {});

expect(result).toEqual(data);
await expect(client['callTool']('test_tool', {})).rejects.toThrow(
'MCP API returned response without structuredContent'
);
});

it('should wrap non-JSON text in message object', async () => {
it('should throw error when plain text is returned without structuredContent', async () => {
const plainText = 'Operation completed successfully';
mockMcpClient.callTool.mockResolvedValue({
content: [{ type: 'text', text: plainText }],
});

const result = await client['callTool']<Record<string, unknown>, { message: string }>(
'test_tool',
{}
await expect(client['callTool']('test_tool', {})).rejects.toThrow(
'MCP API returned response without structuredContent'
);

expect(result).toEqual({ message: plainText });
});

it('should handle text content with special characters', async () => {
const data = { message: 'Success! 🎉 Campaign created.' };
it('should include debug info in error when structuredContent is missing', async () => {
mockMcpClient.callTool.mockResolvedValue({
content: [{ type: 'text', text: JSON.stringify(data) }],
content: [{ type: 'text', text: 'some data' }],
});

const result = await client['callTool']('test_tool', {});

expect(result).toEqual(data);
await expect(client['callTool']('test_tool', {})).rejects.toThrow('test_tool');
await expect(client['callTool']('test_tool', {})).rejects.toThrow('Debug info');
});
});

Expand All @@ -205,7 +219,7 @@ describe('Scope3Client MCP Protocol', () => {
});

await expect(client['callTool']('test_tool', {})).rejects.toThrow(
'Unexpected tool response format'
'MCP API returned response without structuredContent'
);
});

Expand All @@ -215,7 +229,7 @@ describe('Scope3Client MCP Protocol', () => {
});

await expect(client['callTool']('test_tool', {})).rejects.toThrow(
'Unexpected tool response format'
'MCP API returned response without structuredContent'
);
});

Expand Down Expand Up @@ -267,18 +281,18 @@ describe('Scope3Client MCP Protocol', () => {
expect(client.lastDebugInfo?.durationMs).toBeGreaterThanOrEqual(0);
});

it('should store raw response when parsing JSON from text', async () => {
it('should not store debug info when API violates spec (no structuredContent)', async () => {
const data = { id: '456' };
const rawText = JSON.stringify(data);

mockMcpClient.callTool.mockResolvedValue({
content: [{ type: 'text', text: rawText }],
});

await client['callTool']('test_tool', {});

expect(client.lastDebugInfo?.rawResponse).toBe(rawText);
expect(client.lastDebugInfo?.response).toEqual(data);
// Should throw before storing debug info
await expect(client['callTool']('test_tool', {})).rejects.toThrow(
'MCP API returned response without structuredContent'
);
});

it('should not store debug info when debug mode is disabled', async () => {
Expand Down
17 changes: 17 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,23 @@ function formatOutput(data: unknown, format: string): void {
const dataObj = data as Record<string, unknown>;
let actualData: unknown = dataObj.data || data;

// Extract and display human-readable message if present (from MCP content.text)
let humanMessage: string | undefined;
if (typeof actualData === 'object' && actualData && '_message' in actualData) {
const dataRecord = actualData as Record<string, unknown>;
humanMessage = String(dataRecord._message);
// Remove _message from the data to process
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _message, ...rest } = dataRecord;
actualData = rest;
}

// Display the human-readable message first (if not in JSON mode)
if (humanMessage) {
console.log(chalk.cyan(humanMessage));
console.log(); // Blank line before structured data
}

// If the response has an array field, extract it (common pattern for list responses)
// Check for: items, brandAgents, campaigns, agents, etc.
if (typeof actualData === 'object' && actualData && !Array.isArray(actualData)) {
Expand Down
85 changes: 36 additions & 49 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,67 +145,54 @@ export class Scope3Client {
});
}

// MCP tools can return structured content or text content
// Priority: structuredContent > parsed JSON from text > raw text
// MCP tools MUST return structured content according to Scope3 API spec
// If structuredContent is missing, this is an API bug that needs to be fixed upstream

// Check for structuredContent first (preferred)
// Check for structuredContent (required)
if (result.structuredContent) {
// Extract human-readable message from content if present
const content = result.content as Array<{ type: string; text?: string }> | undefined;
const textMessage =
content && content.length > 0 && content[0].type === 'text' ? content[0].text : undefined;

// Wrap response with message if it exists
const response = textMessage
? { _message: textMessage, ...result.structuredContent }
: result.structuredContent;

if (this.debug) {
this.lastDebugInfo = {
toolName,
request: args as Record<string, unknown>,
response: result.structuredContent,
response,
durationMs,
};
}
return result.structuredContent as TResponse;
return response as TResponse;
}

// Fall back to text content
if (result.content && Array.isArray(result.content) && result.content.length > 0) {
const content = result.content[0];
if (content.type === 'text') {
const rawResponse = content.text;

// Try to parse as JSON first, if that fails return the text as-is
try {
const parsed = JSON.parse(rawResponse);

// Store debug info if enabled
if (this.debug) {
this.lastDebugInfo = {
toolName,
request: args as Record<string, unknown>,
response: parsed,
rawResponse,
durationMs,
};
}

return parsed as TResponse;
} catch {
// If not JSON, return the text wrapped in an object
if (this.debug) {
logger.warn('MCP tool returned non-JSON text (no structuredContent)', {
toolName,
textLength: rawResponse.length,
});

this.lastDebugInfo = {
toolName,
request: args as Record<string, unknown>,
response: { message: rawResponse },
rawResponse,
durationMs,
};
}

return { message: rawResponse } as TResponse;
}
}
}
// FAIL LOUDLY: structuredContent is missing
// This helps catch API bugs that need upstream fixes
const content = result.content as Array<{ type: string; text?: string }> | undefined;
const firstContent = content && content.length > 0 ? content[0] : null;
const errorDetails = {
toolName,
hasContent: Boolean(content),
contentType: firstContent?.type,
textPreview:
firstContent?.type === 'text' && firstContent.text
? firstContent.text.substring(0, 200)
: undefined,
};

logger.error('MCP API VIOLATION: Missing structuredContent', errorDetails);

throw new Error('Unexpected tool response format');
throw new Error(
`MCP API returned response without structuredContent for tool "${toolName}". ` +
`This violates the Scope3 API specification. ` +
`The API must be fixed to include structuredContent in all responses. ` +
`Debug info: ${JSON.stringify(errorDetails)}`
);
}

protected getClient(): Client {
Expand Down