From d36a27d9551fb984734f50771fb3896222ea689f Mon Sep 17 00:00:00 2001 From: Terry Chen Date: Sat, 11 Jul 2026 11:54:20 +0800 Subject: [PATCH 1/2] fix: extract logs field from application logs API response Coolify's GET /applications/{uuid}/logs returns {"logs": "..."}, not a bare string. getApplicationLogs treated the whole response as the log text, so downstream truncateLogs() called .split() on an object and threw "logs.split is not a function" on every call. Fixes #120 --- CLAUDE.md | 1 + src/__tests__/coolify-client.test.ts | 16 ++++++++-------- src/lib/coolify-client.ts | 5 ++++- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9e8a6b6..0c40113 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,7 @@ The Coolify OpenAPI docs are unreliable — always test against the real API. Kn - **Validation errors vary in format** — The `errors` field in API error responses can contain `string[]` or plain `string` values. The client handles both. - **`/deployments/applications/{uuid}` returns a wrapper, not an array** — OpenAPI claims `type: array`, but real Coolify returns `{ count, deployments: [...] }`. The client normalizes this in `listApplicationDeployments` (also accepts `{ data: [...] }` and bare arrays as fallbacks). See issue #24. - **`docker_compose_domains` for docker-compose Applications** — Applications using the dockercompose build pack cannot use `domains`/`fqdn`; they require `docker_compose_domains: [{name: "service-name", domain: "https://..."}]`. Passing `domains` returns 422 `The domains field cannot be used for dockercompose applications.`. This is distinct from Coolify Service type (which needs `docker_compose_raw` Traefik labels). See issue #36. +- **`/applications/{uuid}/logs` returns a wrapper, not a bare string** — OpenAPI/naming suggests the endpoint returns raw log text, but real Coolify returns `{ "logs": "..." }`. The client extracts `.logs` in `getApplicationLogs`. See issue #120. ## TypeScript Standards diff --git a/src/__tests__/coolify-client.test.ts b/src/__tests__/coolify-client.test.ts index d766290..eb9c007 100644 --- a/src/__tests__/coolify-client.test.ts +++ b/src/__tests__/coolify-client.test.ts @@ -1841,7 +1841,7 @@ describe('CoolifyClient', () => { }); it('should get application logs', async () => { - mockFetch.mockResolvedValueOnce(mockResponse('log line 1\nlog line 2')); + mockFetch.mockResolvedValueOnce(mockResponse({ logs: 'log line 1\nlog line 2' })); const result = await client.getApplicationLogs('app-uuid', 50); @@ -3521,7 +3521,7 @@ describe('CoolifyClient', () => { it('should aggregate all application data successfully', async () => { mockFetch .mockResolvedValueOnce(mockResponse(mockApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce(mockResponse(mockDeployments)); @@ -3553,7 +3553,7 @@ describe('CoolifyClient', () => { it('completes diagnosis when deployments come back as a wrapper object (issue #24)', async () => { mockFetch .mockResolvedValueOnce(mockResponse(mockApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce( mockResponse({ count: mockDeployments.length, deployments: mockDeployments }), @@ -3572,7 +3572,7 @@ describe('CoolifyClient', () => { try { mockFetch .mockResolvedValueOnce(mockResponse(mockApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce(mockResponse({ foo: 'bar' })); @@ -3591,7 +3591,7 @@ describe('CoolifyClient', () => { const unhealthyApp = { ...mockApp, status: 'exited:unhealthy' }; mockFetch .mockResolvedValueOnce(mockResponse(unhealthyApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce(mockResponse([])); @@ -3608,7 +3608,7 @@ describe('CoolifyClient', () => { ]; mockFetch .mockResolvedValueOnce(mockResponse(mockApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce(mockResponse(failedDeployments)); @@ -3651,7 +3651,7 @@ describe('CoolifyClient', () => { mockFetch .mockResolvedValueOnce(mockResponse(mockApps)) // listApplications for lookup .mockResolvedValueOnce(mockResponse(mockApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce(mockResponse(mockDeployments)); @@ -3671,7 +3671,7 @@ describe('CoolifyClient', () => { mockFetch .mockResolvedValueOnce(mockResponse(mockApps)) // listApplications for lookup .mockResolvedValueOnce(mockResponse(mockApp)) - .mockResolvedValueOnce(mockResponse(mockLogs)) + .mockResolvedValueOnce(mockResponse({ logs: mockLogs })) .mockResolvedValueOnce(mockResponse(mockEnvVars)) .mockResolvedValueOnce(mockResponse(mockDeployments)); diff --git a/src/lib/coolify-client.ts b/src/lib/coolify-client.ts index 98fcb41..fa21acf 100644 --- a/src/lib/coolify-client.ts +++ b/src/lib/coolify-client.ts @@ -855,7 +855,10 @@ export class CoolifyClient { } async getApplicationLogs(uuid: string, lines: number = 200): Promise { - return this.request(`/applications/${encodeURIComponent(uuid)}/logs?lines=${lines}`); + const response = await this.request<{ logs: string }>( + `/applications/${encodeURIComponent(uuid)}/logs?lines=${lines}`, + ); + return response.logs; } async startApplication( From 6a566c983a0531cb745dc42faffd719aea1cc2f2 Mon Sep 17 00:00:00 2001 From: Terry Chen Date: Sat, 11 Jul 2026 12:03:19 +0800 Subject: [PATCH 2/2] fix: validate application logs response shape before returning Address PR #48 review feedback: getApplicationLogs assumed logs is always a string without checking, silently letting an unrecognized shape propagate a non-string past this boundary. Mirrors the normalizeDeploymentsResponse pattern (issue #24): accept the known {logs: string} shape or a bare string, throw a clear error otherwise. --- src/__tests__/coolify-client.test.ts | 16 ++++++++++++++++ src/lib/coolify-client.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/__tests__/coolify-client.test.ts b/src/__tests__/coolify-client.test.ts index eb9c007..b6723cc 100644 --- a/src/__tests__/coolify-client.test.ts +++ b/src/__tests__/coolify-client.test.ts @@ -1852,6 +1852,22 @@ describe('CoolifyClient', () => { ); }); + it('accepts a bare string response for application logs (forward-compat)', async () => { + mockFetch.mockResolvedValueOnce(mockResponse('log line 1\nlog line 2')); + + const result = await client.getApplicationLogs('app-uuid', 50); + + expect(result).toBe('log line 1\nlog line 2'); + }); + + it('throws on unrecognized application logs response shape', async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ unexpected: true })); + + await expect(client.getApplicationLogs('app-uuid', 50)).rejects.toThrow( + /unrecognized response shape/, + ); + }); + it('should restart an application', async () => { mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Restarted' })); diff --git a/src/lib/coolify-client.ts b/src/lib/coolify-client.ts index fa21acf..b02b24b 100644 --- a/src/lib/coolify-client.ts +++ b/src/lib/coolify-client.ts @@ -374,6 +374,28 @@ function normalizeDeploymentsResponse(raw: unknown): Deployment[] { return []; } +// Coolify normally wraps logs as `{ logs: string }`, but also accepts a bare +// string for forward-compat with any endpoint variant that returns raw text. +function normalizeApplicationLogsResponse(raw: unknown): string { + if (typeof raw === 'string') return raw; + if ( + raw !== null && + typeof raw === 'object' && + typeof (raw as { logs?: unknown }).logs === 'string' + ) { + return (raw as { logs: string }).logs; + } + const summary = + raw === null + ? 'null' + : raw === undefined + ? 'undefined' + : typeof raw === 'object' + ? `object keys=${JSON.stringify(Object.keys(raw as object))}` + : typeof raw; + throw new Error(`[coolify-mcp] getApplicationLogs: unrecognized response shape (${summary})`); +} + function toDeploymentEssential(dep: Deployment): DeploymentEssential { return { uuid: dep.uuid, @@ -855,10 +877,10 @@ export class CoolifyClient { } async getApplicationLogs(uuid: string, lines: number = 200): Promise { - const response = await this.request<{ logs: string }>( + const raw = await this.request( `/applications/${encodeURIComponent(uuid)}/logs?lines=${lines}`, ); - return response.logs; + return normalizeApplicationLogsResponse(raw); } async startApplication(