diff --git a/.changeset/readme-update.md b/.changeset/readme-update.md new file mode 100644 index 0000000..663cef8 --- /dev/null +++ b/.changeset/readme-update.md @@ -0,0 +1,4 @@ +--- +--- + +Documentation update for scope3 package installation diff --git a/README.md b/README.md index 8583d9f..65d7222 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/__tests__/client-mcp.test.ts b/src/__tests__/client-mcp.test.ts index 577f14b..a0bc4ff 100644 --- a/src/__tests__/client-mcp.test.ts +++ b/src/__tests__/client-mcp.test.ts @@ -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'], 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'], typeof expectedData>( @@ -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'], { 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'); }); }); @@ -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' ); }); @@ -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' ); }); @@ -267,7 +281,7 @@ 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); @@ -275,10 +289,10 @@ describe('Scope3Client MCP Protocol', () => { 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 () => { diff --git a/src/cli.ts b/src/cli.ts index 75fefdd..5bfed39 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -193,6 +193,23 @@ function formatOutput(data: unknown, format: string): void { const dataObj = data as Record; 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; + 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)) { diff --git a/src/client.ts b/src/client.ts index 47765ec..beb649d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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, - 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, - 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, - 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 {