diff --git a/.changeset/kontena-entry-slug-lookup.md b/.changeset/kontena-entry-slug-lookup.md new file mode 100644 index 0000000..de598d5 --- /dev/null +++ b/.changeset/kontena-entry-slug-lookup.md @@ -0,0 +1,8 @@ +--- +"@sawala/cli": patch +"@sawala/mcp": patch +--- + +`sawala kontena entry create/update/delete` and the matching `sawala_kontena_create_entry`, `sawala_kontena_update_entry` and `sawala_kontena_delete_entry` MCP tools now work when you name the schema by its slug — previously they failed with `NOT_FOUND (…/schemas/)` for every schema. + +The commands look the schema up first to decide whether to write to the `single` or the `collection` content route, and that lookup only resolves a schema's ULID. The content route it feeds only resolves the schema's *slug*, so the identifier that made the write succeed was exactly the one that made the lookup fail, and there was no value that worked for both. The lookup now falls back to listing the project's schemas and matching by slug, the same way `sawala kontena schema get` already did. When the schema really is absent you get `Schema 'x' not found. Available slugs: …` instead of a bare `NOT_FOUND`. diff --git a/package-lock.json b/package-lock.json index 68fc639..da90bba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4503,7 +4503,7 @@ }, "packages/kodena": { "name": "@sawala/kodena", - "version": "0.10.0", + "version": "0.11.0", "license": "MIT", "dependencies": { "commander": "^12.0.0", @@ -4530,7 +4530,7 @@ }, "packages/kodena-mcp": { "name": "@sawala/kodena-mcp", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", @@ -4552,7 +4552,7 @@ }, "packages/sawala": { "name": "@sawala/cli", - "version": "0.9.0", + "version": "0.14.1", "license": "MIT", "dependencies": { "commander": "^12.0.0", diff --git a/packages/sawala-mcp/src/lib/kontena-schema.ts b/packages/sawala-mcp/src/lib/kontena-schema.ts new file mode 100644 index 0000000..a4d0df8 --- /dev/null +++ b/packages/sawala-mcp/src/lib/kontena-schema.ts @@ -0,0 +1,60 @@ +import { ApiError, apiFetch } from './api-client' +import type { CliContext } from './auth' + +interface SchemaRow { + id: string + slug: string + type: string +} + +interface SchemaListResponse { + data: SchemaRow[] +} + +interface SchemaTypeResponse { + type: string + [k: string]: unknown +} + +/** + * Resolve a schema's `single`/`collection` type so an entry tool can pick the + * right content sub-path. + * + * The schema-get route resolves ULIDs only, but the content routes this feeds + * resolve the schema by SLUG — so the identifier that makes the write succeed + * is exactly the one that 404s on the lookup. Every entry tool therefore needs + * the same 404 → list → match-by-slug fallback that `sawala_kontena_get_schema` + * already has. The list rows carry `type`, so the fallback costs one request, + * not two. + */ +export async function resolveSchemaType( + ctx: CliContext, + projectId: string, + schemaSlug: string, +): Promise<'single' | 'collection'> { + const base = `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas` + + try { + const schema = await apiFetch( + ctx, + `${base}/${encodeURIComponent(schemaSlug)}`, + ) + return normalize(schema.type) + } catch (err) { + if (!(err instanceof ApiError) || err.status !== 404) throw err + } + + const listResult = await apiFetch(ctx, `${base}?limit=100`) + const match = listResult.data.find((s) => s.slug === schemaSlug) + if (!match) { + const available = listResult.data.map((s) => s.slug).join(', ') || '(none)' + throw new Error(`Schema '${schemaSlug}' not found. Available slugs: ${available}.`) + } + return normalize(match.type) +} + +// Anything that is not explicitly 'single' routes as a collection, matching the +// pre-existing `schemaInfo.type === 'single' ? … : …` behaviour of every caller. +function normalize(t: string): 'single' | 'collection' { + return t === 'single' ? 'single' : 'collection' +} diff --git a/packages/sawala-mcp/src/tools/kontena-create-entry.ts b/packages/sawala-mcp/src/tools/kontena-create-entry.ts index 0912abf..114587d 100644 --- a/packages/sawala-mcp/src/tools/kontena-create-entry.ts +++ b/packages/sawala-mcp/src/tools/kontena-create-entry.ts @@ -1,13 +1,9 @@ import { z } from 'zod' import { apiFetch } from '../lib/api-client' +import { resolveSchemaType } from '../lib/kontena-schema' import type { CliContext } from '../lib/auth' import { zodParser, type ToolDefinition, type ToolInputSchema } from './types' -interface SchemaTypeResponse { - type: string - [k: string]: unknown -} - const inputZod = z .object({ schemaSlug: z.string().min(1), @@ -61,11 +57,7 @@ export const kontenaCreateEntryTool: ToolDefinition = { const projectId = ctx.activeProjectId const payload: Record = { ...input.entry } if (input.publish) payload.status = 'published' - const schemaInfo = await apiFetch( - ctx, - `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas/${encodeURIComponent(input.schemaSlug)}`, - ) - const subpath = schemaInfo.type === 'single' ? 'single' : 'collection' + const subpath = await resolveSchemaType(ctx, projectId, input.schemaSlug) return await apiFetch( ctx, `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/${subpath}/${encodeURIComponent(input.schemaSlug)}`, diff --git a/packages/sawala-mcp/src/tools/kontena-delete-entry.ts b/packages/sawala-mcp/src/tools/kontena-delete-entry.ts index 31d4ee2..b46f27f 100644 --- a/packages/sawala-mcp/src/tools/kontena-delete-entry.ts +++ b/packages/sawala-mcp/src/tools/kontena-delete-entry.ts @@ -1,13 +1,9 @@ import { z } from 'zod' import { apiFetch } from '../lib/api-client' +import { resolveSchemaType } from '../lib/kontena-schema' import type { CliContext } from '../lib/auth' import { zodParser, type ToolDefinition, type ToolInputSchema } from './types' -interface SchemaTypeResponse { - type: string - [k: string]: unknown -} - const inputZod = z .object({ schemaSlug: z.string().min(1), @@ -68,12 +64,9 @@ export const kontenaDeleteEntryTool: ToolDefinition = { ) } const projectId = ctx.activeProjectId - const schemaInfo = await apiFetch( - ctx, - `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas/${encodeURIComponent(input.schemaSlug)}`, - ) + const schemaType = await resolveSchemaType(ctx, projectId, input.schemaSlug) const url = - schemaInfo.type === 'single' + schemaType === 'single' ? `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/single/${encodeURIComponent(input.schemaSlug)}` + (input.locale ? `?locale=${encodeURIComponent(input.locale)}` : '') : `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/collection/${encodeURIComponent(input.schemaSlug)}/${encodeURIComponent(input.slugOrId)}` diff --git a/packages/sawala-mcp/src/tools/kontena-update-entry.ts b/packages/sawala-mcp/src/tools/kontena-update-entry.ts index 3caefe2..57bd5a1 100644 --- a/packages/sawala-mcp/src/tools/kontena-update-entry.ts +++ b/packages/sawala-mcp/src/tools/kontena-update-entry.ts @@ -1,13 +1,9 @@ import { z } from 'zod' import { apiFetch } from '../lib/api-client' +import { resolveSchemaType } from '../lib/kontena-schema' import type { CliContext } from '../lib/auth' import { zodParser, type ToolDefinition, type ToolInputSchema } from './types' -interface SchemaTypeResponse { - type: string - [k: string]: unknown -} - const inputZod = z .object({ schemaSlug: z.string().min(1), @@ -64,12 +60,9 @@ export const kontenaUpdateEntryTool: ToolDefinition = { const projectId = ctx.activeProjectId const payload: Record = { ...input.patch } if (input.publish) payload.status = 'published' - const schemaInfo = await apiFetch( - ctx, - `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas/${encodeURIComponent(input.schemaSlug)}`, - ) + const schemaType = await resolveSchemaType(ctx, projectId, input.schemaSlug) const url = - schemaInfo.type === 'single' + schemaType === 'single' ? `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/single/${encodeURIComponent(input.schemaSlug)}` : `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/collection/${encodeURIComponent(input.schemaSlug)}/${encodeURIComponent(input.slugOrId)}` return await apiFetch(ctx, url, { method: 'PUT', body: payload }) diff --git a/packages/sawala-mcp/test/tools/kontena-create-entry.test.ts b/packages/sawala-mcp/test/tools/kontena-create-entry.test.ts index d18fd35..cc1cd01 100644 --- a/packages/sawala-mcp/test/tools/kontena-create-entry.test.ts +++ b/packages/sawala-mcp/test/tools/kontena-create-entry.test.ts @@ -74,6 +74,55 @@ describe('sawala_kontena_create_entry', () => { ) }) + // The schema-get route resolves ULIDs only, while the content route resolves + // the schema by slug — so the identifier that makes the write succeed is the + // one that 404s on the lookup. Without the fallback, this tool is unusable. + it('falls back to listing and matching by slug when schema-get 404s', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/posts')) return jsonResponse({ error: 'NOT_FOUND' }, 404) + if (url.endsWith('/schemas?limit=100')) { + return jsonResponse({ + data: [ + { id: 'sch_9', slug: 'other', name: 'Other', type: 'single' }, + { id: 'sch_1', slug: 'posts', name: 'Posts', type: 'collection' }, + ], + meta: { pagination: { limit: 100, nextCursor: null, hasMore: false } }, + }) + } + return jsonResponse({ id: 'ent_1' }, 201) + }) + vi.stubGlobal('fetch', fetchMock) + const out = await kontenaCreateEntryTool.handle( + { schemaSlug: 'posts', entry: { slug: 'hello', locale: 'en', data: { x: 1 } } }, + baseCtx, + ) + expect(fetchMock).toHaveBeenCalledTimes(3) + const [url3, init3] = fetchMock.mock.calls[2] as unknown as [string, RequestInit] + expect(url3).toBe( + 'https://api.sawala.cloud/cli/kontena/projects/proj_01abc/content/collection/posts', + ) + expect(init3.method).toBe('POST') + expect(out).toEqual({ id: 'ent_1' }) + }) + + it('reports the available slugs when the schema is genuinely absent', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/nope')) return jsonResponse({ error: 'NOT_FOUND' }, 404) + return jsonResponse({ + data: [{ id: 'sch_1', slug: 'posts', name: 'Posts', type: 'collection' }], + meta: { pagination: { limit: 100, nextCursor: null, hasMore: false } }, + }) + }) + vi.stubGlobal('fetch', fetchMock) + await expect( + kontenaCreateEntryTool.handle( + { schemaSlug: 'nope', entry: { slug: 'x', locale: 'en', data: {} } }, + baseCtx, + ), + ).rejects.toThrow(/Schema 'nope' not found\. Available slugs: posts\./) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + it("publish:true injects status='published' into the POST body", async () => { const fetchMock = vi.fn(async (url: string) => { if (url.endsWith('/schemas/posts')) { diff --git a/packages/sawala/src/commands/kontena.ts b/packages/sawala/src/commands/kontena.ts index ad3c9f9..48ad465 100644 --- a/packages/sawala/src/commands/kontena.ts +++ b/packages/sawala/src/commands/kontena.ts @@ -100,11 +100,33 @@ async function fetchSchemaType( projectId: string, schemaSlug: string, ): Promise<'single' | 'collection'> { - const schema = await apiFetch( - ctx, - `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas/${encodeURIComponent(schemaSlug)}`, - ) - const t = schema.type + const base = `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas` + + // The schema-get route resolves ULIDs only, but the content routes this + // guards resolve the schema by SLUG. So the argument that makes the write + // work is exactly the one that 404s here — without the fallback below, every + // `entry` subcommand is unusable. Mirrors `schema get`, except that the list + // rows already carry `type`, so no second GET is needed. + try { + const schema = await apiFetch( + ctx, + `${base}/${encodeURIComponent(schemaSlug)}`, + ) + return assertSchemaType(schema.type, schemaSlug) + } catch (err) { + if (!(err instanceof ApiError) || err.status !== 404) throw err + } + + const listResult = await apiFetch(ctx, `${base}?limit=100`) + const match = listResult.data.find((s) => s.slug === schemaSlug) + if (!match) { + const available = listResult.data.map((s) => s.slug).join(', ') || '(none)' + throw new Error(`Schema '${schemaSlug}' not found. Available slugs: ${available}.`) + } + return assertSchemaType(match.type, schemaSlug) +} + +function assertSchemaType(t: string, schemaSlug: string): 'single' | 'collection' { if (t !== 'single' && t !== 'collection') { throw new Error(`Schema '${schemaSlug}' has unexpected type '${t}'.`) } diff --git a/packages/sawala/test/kontena.test.ts b/packages/sawala/test/kontena.test.ts index 473bfd5..30cb9de 100644 --- a/packages/sawala/test/kontena.test.ts +++ b/packages/sawala/test/kontena.test.ts @@ -559,6 +559,115 @@ describe('sawala kontena entry create / update / delete', () => { ) }) + // The schema-get route resolves ULIDs only, while the content route resolves + // the schema by slug — so the identifier that makes the write succeed is the + // one that 404s on the lookup. Without the list-and-match fallback, every + // `entry` subcommand is unusable against a schema named by slug. + it('create falls back to matching by slug when schema-get 404s', async () => { + const entry = { slug: 'hello', locale: 'en', data: { title: 'Hi' } } + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/posts')) return jsonResponse({ error: 'NOT_FOUND' }, 404) + if (url.endsWith('/schemas?limit=100')) { + return jsonResponse({ + data: [ + { id: 'sch_9', documentId: 'doc_9', slug: 'other', name: 'Other', type: 'single' }, + { id: 'sch_1', documentId: 'doc_1', slug: 'posts', name: 'Posts', type: 'collection' }, + ], + meta: { pagination: { limit: 100, nextCursor: null, hasMore: false } }, + }) + } + return jsonResponse({ id: 'ent_1', ...entry }, 201) + }) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'entry', + 'create', + 'posts', + '--data', + JSON.stringify(entry), + ]) + cap.restore() + + // 404'd get, then the list, then the write — and the write still addresses + // the schema by SLUG, which is what the content route resolves. + expect(fetchMock).toHaveBeenCalledTimes(3) + const [url3, init3] = fetchMock.mock.calls[2] as unknown as [string, RequestInit] + expect(url3).toBe( + `${API_BASE}/cli/kontena/projects/${PROJECT_ID}/content/collection/posts`, + ) + expect(init3.method).toBe('POST') + }) + + it('create routes a single-type schema correctly via the slug fallback', async () => { + const entry = { locale: 'en', data: { siteTitle: 'Sawala' } } + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/site-settings')) return jsonResponse({ error: 'NOT_FOUND' }, 404) + if (url.endsWith('/schemas?limit=100')) { + return jsonResponse({ + data: [ + { + id: 'sch_2', + documentId: 'doc_2', + slug: 'site-settings', + name: 'Site Settings', + type: 'single', + }, + ], + meta: { pagination: { limit: 100, nextCursor: null, hasMore: false } }, + }) + } + return jsonResponse({ id: 'ent_1', ...entry }, 201) + }) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'entry', + 'create', + 'site-settings', + '--data', + JSON.stringify(entry), + ]) + cap.restore() + const [url3] = fetchMock.mock.calls[2] as unknown as [string, RequestInit] + expect(url3).toBe( + `${API_BASE}/cli/kontena/projects/${PROJECT_ID}/content/single/site-settings`, + ) + }) + + it('create reports the available slugs when the schema is genuinely absent', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/nope')) return jsonResponse({ error: 'NOT_FOUND' }, 404) + return jsonResponse({ + data: [ + { id: 'sch_1', documentId: 'doc_1', slug: 'posts', name: 'Posts', type: 'collection' }, + ], + meta: { pagination: { limit: 100, nextCursor: null, hasMore: false } }, + }) + }) + vi.stubGlobal('fetch', fetchMock) + await expect( + createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'entry', + 'create', + 'nope', + '--data', + JSON.stringify({ slug: 'x', locale: 'en', data: {} }), + ]), + ).rejects.toThrow(/Schema 'nope' not found\. Available slugs: posts\./) + // Never reached the write. + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + it('create --publish injects status=published into the body', async () => { const entry = { slug: 'hello', locale: 'en', data: { title: 'Hi' } } const fetchMock = vi.fn(async (url: string) => {