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
8 changes: 8 additions & 0 deletions .changeset/kontena-entry-slug-lookup.md
Original file line number Diff line number Diff line change
@@ -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/<slug>)` 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`.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 60 additions & 0 deletions packages/sawala-mcp/src/lib/kontena-schema.ts
Original file line number Diff line number Diff line change
@@ -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<SchemaTypeResponse>(
ctx,
`${base}/${encodeURIComponent(schemaSlug)}`,
)
return normalize(schema.type)
} catch (err) {
if (!(err instanceof ApiError) || err.status !== 404) throw err
}

const listResult = await apiFetch<SchemaListResponse>(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'
}
12 changes: 2 additions & 10 deletions packages/sawala-mcp/src/tools/kontena-create-entry.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -61,11 +57,7 @@ export const kontenaCreateEntryTool: ToolDefinition<Input> = {
const projectId = ctx.activeProjectId
const payload: Record<string, unknown> = { ...input.entry }
if (input.publish) payload.status = 'published'
const schemaInfo = await apiFetch<SchemaTypeResponse>(
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<unknown>(
ctx,
`/cli/kontena/projects/${encodeURIComponent(projectId)}/content/${subpath}/${encodeURIComponent(input.schemaSlug)}`,
Expand Down
13 changes: 3 additions & 10 deletions packages/sawala-mcp/src/tools/kontena-delete-entry.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -68,12 +64,9 @@ export const kontenaDeleteEntryTool: ToolDefinition<Input> = {
)
}
const projectId = ctx.activeProjectId
const schemaInfo = await apiFetch<SchemaTypeResponse>(
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)}`
Expand Down
13 changes: 3 additions & 10 deletions packages/sawala-mcp/src/tools/kontena-update-entry.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -64,12 +60,9 @@ export const kontenaUpdateEntryTool: ToolDefinition<Input> = {
const projectId = ctx.activeProjectId
const payload: Record<string, unknown> = { ...input.patch }
if (input.publish) payload.status = 'published'
const schemaInfo = await apiFetch<SchemaTypeResponse>(
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<unknown>(ctx, url, { method: 'PUT', body: payload })
Expand Down
49 changes: 49 additions & 0 deletions packages/sawala-mcp/test/tools/kontena-create-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
32 changes: 27 additions & 5 deletions packages/sawala/src/commands/kontena.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,33 @@ async function fetchSchemaType(
projectId: string,
schemaSlug: string,
): Promise<'single' | 'collection'> {
const schema = await apiFetch<SchemaGetResponse>(
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<SchemaGetResponse>(
ctx,
`${base}/${encodeURIComponent(schemaSlug)}`,
)
return assertSchemaType(schema.type, schemaSlug)
} catch (err) {
if (!(err instanceof ApiError) || err.status !== 404) throw err
}

const listResult = await apiFetch<SchemaListResponse>(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}'.`)
}
Expand Down
109 changes: 109 additions & 0 deletions packages/sawala/test/kontena.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading