Skip to content

Commit 344a012

Browse files
fix(cli): render single-key resource envelopes, and column the new domains
`sim mcp-servers create` created the server, exited 0, and printed nothing. The v2 route answers `{ data: { mcpServer: {...} } }`, and the record renderer keeps only scalar fields — one key holding an object left it with none. Unwrap a lone object-valued key before rendering; a payload with siblings (`{ row, operation }` from upsert) is a real result and is left alone. The five domains that arrived with the last generation had no contract columns, so `mcp-servers list` inferred 20 including `hasOauthClientSecret`. Give each a column set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU
1 parent 9a0e1e3 commit 344a012

3 files changed

Lines changed: 124 additions & 3 deletions

File tree

packages/sim-cli/src/contract/commands.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,52 @@ export const CLI_CONTRACT: CliContract = {
145145
{ header: 'chunks', path: 'chunkCount' },
146146
],
147147
},
148+
// Without these the inferred fallback dumps every scalar field — 20 columns
149+
// for an MCP server, including `hasOauthClientSecret`.
150+
listMcpServers: {
151+
columns: [
152+
{ header: 'id' },
153+
{ header: 'name' },
154+
{ header: 'transport' },
155+
{ header: 'url' },
156+
{ header: 'status', path: 'connectionStatus' },
157+
{ header: 'tools', path: 'toolCount' },
158+
{ header: 'enabled', format: 'bool' },
159+
],
160+
},
161+
listSkills: {
162+
columns: [
163+
{ header: 'id' },
164+
{ header: 'name' },
165+
{ header: 'description' },
166+
{ header: 'updated', path: 'updatedAt', format: 'timestamp' },
167+
],
168+
},
169+
listCustomTools: {
170+
columns: [
171+
{ header: 'id' },
172+
{ header: 'name' },
173+
{ header: 'description' },
174+
{ header: 'updated', path: 'updatedAt', format: 'timestamp' },
175+
],
176+
},
177+
listFolders: {
178+
columns: [
179+
{ header: 'id' },
180+
{ header: 'name' },
181+
{ header: 'parent', path: 'parentId' },
182+
{ header: 'updated', path: 'updatedAt', format: 'timestamp' },
183+
],
184+
},
185+
listCredentials: {
186+
columns: [
187+
{ header: 'id' },
188+
{ header: 'name' },
189+
{ header: 'provider' },
190+
{ header: 'updated', path: 'updatedAt', format: 'timestamp' },
191+
],
192+
},
193+
148194
listAuditLogs: {
149195
columns: [
150196
{ header: 'at', path: 'createdAt', format: 'timestamp' },

packages/sim-cli/src/runtime/build.test.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@ import { buildGeneratedCommands } from './build.js'
1313
* catch that class of bug.
1414
*/
1515

16-
const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn() }))
16+
const { mockRequest, output } = vi.hoisted(() => ({
17+
mockRequest: vi.fn(),
18+
output: { format: 'json' },
19+
}))
1720

1821
vi.mock('../context.js', () => ({
1922
clientFrom: () => ({
2023
client: { request: mockRequest, requireWorkspace: () => 'ws_local' },
21-
profile: { workspaceId: 'ws_local', output: 'json', name: 'default', apiKey: 'k' },
24+
profile: { workspaceId: 'ws_local', output: output.format, name: 'default', apiKey: 'k' },
2225
}),
2326
}))
2427

@@ -109,6 +112,59 @@ describe('commands parsed through commander', () => {
109112
})
110113
})
111114

115+
describe('single-resource rendering', () => {
116+
async function lines(argv: string[], data: unknown, format = 'json'): Promise<string[]> {
117+
mockRequest.mockReset()
118+
mockRequest.mockResolvedValue({ data })
119+
const captured: string[] = []
120+
vi.spyOn(console, 'log').mockImplementation((line: string) => {
121+
captured.push(line)
122+
})
123+
output.format = format
124+
try {
125+
await program().parseAsync(['node', 'sim', ...argv])
126+
} finally {
127+
output.format = 'json'
128+
}
129+
return captured
130+
}
131+
132+
it('unwraps the single-key envelope a resource is returned in', async () => {
133+
// `createMcpServer` answers `{ data: { mcpServer: {...} } }`. Rendering that
134+
// as-is found one key holding an object, filtered it out as non-scalar, and
135+
// printed nothing at all — the server was created and the CLI said so
136+
// nowhere. Same silent-empty class as the body-cursor bug below.
137+
const printed = await lines(
138+
[
139+
'mcp-servers',
140+
'create',
141+
'--name',
142+
'Deepwiki',
143+
'--transport',
144+
'streamable-http',
145+
'--url',
146+
'https://mcp.deepwiki.com/mcp',
147+
],
148+
{ mcpServer: { id: 'mcp-1', name: 'Deepwiki', enabled: true } },
149+
'text'
150+
)
151+
152+
expect(printed.join('\n')).toMatch(/mcp-1/)
153+
expect(printed.join('\n')).toMatch(/Deepwiki/)
154+
})
155+
156+
it('leaves a payload with sibling keys intact', async () => {
157+
// `upsertTableRow` returns `{ row, operation }` — two real fields, not an
158+
// envelope. Unwrapping there would drop whether it inserted or updated.
159+
const printed = await lines(['tables', 'upsert', 'tbl_1', '--data', '{}'], {
160+
row: { id: 'r1' },
161+
operation: 'inserted',
162+
})
163+
164+
expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' })
165+
})
166+
})
167+
112168
describe('pagination slot', () => {
113169
it('pages a body-cursor operation and renders its rows', async () => {
114170
// `queryRows` is a POST whose cursor is in the body, not the query. Reading

packages/sim-cli/src/runtime/build.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,25 @@ function inferColumns(rows: unknown[]): Column<unknown>[] {
9494
}))
9595
}
9696

97+
/**
98+
* Unwraps the single-key envelope several v2 responses put their resource in —
99+
* `{ mcpServer }`, `{ knowledgeBase }`, `{ row }`, `{ document }`, `{ table }`.
100+
*
101+
* Without this the record renderer sees one key whose value is an object,
102+
* filters it out as non-scalar, and prints nothing at all: `sim mcp-servers
103+
* create` exited 0 having created the server and said nothing about it.
104+
*
105+
* Only a lone key is unwrapped. A payload with siblings (`{ row, operation }`
106+
* from upsert) is a real multi-field result and is rendered as it stands.
107+
*/
108+
function unwrapResource(data: unknown): unknown {
109+
if (!data || typeof data !== 'object' || Array.isArray(data)) return data
110+
const entries = Object.entries(data)
111+
if (entries.length !== 1) return data
112+
const [, value] = entries[0]
113+
return value && typeof value === 'object' && !Array.isArray(value) ? value : data
114+
}
115+
97116
/** The operation's one-line help, taken from the OpenAPI summary at generation time. */
98117
function summaryFor(operation: V2OperationName): string | undefined {
99118
return (V2_OPERATIONS[operation] as { summary?: string }).summary
@@ -263,7 +282,7 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri
263282
query: request.query,
264283
body: request.body,
265284
})
266-
const data = result?.data ?? result
285+
const data = unwrapResource(result?.data ?? result)
267286

268287
if (Array.isArray(data)) {
269288
// Reached when a non-paginated operation answers with a collection.

0 commit comments

Comments
 (0)