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
5 changes: 5 additions & 0 deletions .changeset/mcp-server-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@baseplate-dev/project-builder-server': patch
---

MCP server improvements: apply fixRefDeletions and applyDefinitionFixes when staging changes, add entity search action, expose auto-fix suggestions with apply-fix action, add plugin management actions (list, configure, disable), blacklist plugin entity type from generic entity operations
5 changes: 5 additions & 0 deletions .changeset/mcp-server-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@baseplate-dev/project-builder-lib': patch
---

Fix entity navigation for discriminated union array children (e.g. admin sections) by stripping leading discriminated-union-array element from relative paths in collectEntityMetadata
42 changes: 26 additions & 16 deletions packages/project-builder-lib/src/parser/walk-schema-structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,14 @@ import { getSchemaChildren } from './schema-structure.js';
// ---------------------------------------------------------------------------

/**
* Represents a single deterministic step in navigating from a parent entity
* (or definition root) to a child entity array.
* Represents a single step in navigating through a schema structure.
*
* Only deterministic navigation steps are represented:
* - `object-key`: navigate into an object property
* - `tuple-index`: navigate into a specific tuple position
* - `discriminated-union-array`: enter an array and pick the unique element
* matching a discriminator value
*
* Non-deterministic structures (plain arrays, records) are not represented
* in the path — the walker descends into them without adding path elements.
* - `array`: descend into a plain array's element schema (non-deterministic)
* - `record`: descend into a record's value schema (non-deterministic)
*/
export type SchemaPathElement =
| { type: 'object-key'; key: string }
Expand All @@ -26,7 +23,9 @@ export type SchemaPathElement =
type: 'discriminated-union-array';
discriminatorKey: string;
value: string;
};
}
| { type: 'array' }
| { type: 'record' };

// ---------------------------------------------------------------------------
// Visitor types
Expand Down Expand Up @@ -64,12 +63,13 @@ export interface SchemaStructureVisitor {
* at every schema node.
*
* Unlike `walkDataWithSchema`, this operates on the schema alone.
* Only deterministic navigation steps produce path elements:
* - Object keys and tuple indices add path elements
* - Arrays of discriminated unions add `discriminated-union-array` elements
* (one per branch)
* Every structural descent produces a path element:
* - Object keys → `object-key`
* - Tuple indices → `tuple-index`
* - Arrays of discriminated unions → `discriminated-union-array` (one per branch)
* - Plain arrays → `array`
* - Records → `record`
* - Discriminated unions on objects are transparent (no path element)
* - Plain arrays and records are descended into without path elements
*
* Uses a `Set<z.ZodType>` circular-reference guard with delete-on-backtrack
* so the same schema can appear at different paths.
Expand Down Expand Up @@ -150,8 +150,13 @@ function walkNode(
visited,
);
} else {
// Plain array — descend without adding a path element
walkNode(children.elementSchema, path, visitors, visited);
// Plain array — descend with an array path element
walkNode(
children.elementSchema,
[...path, { type: 'array' }],
visitors,
visited,
);
}
break;
}
Expand Down Expand Up @@ -182,8 +187,13 @@ function walkNode(
}

case 'record': {
// Non-deterministic — walk without path element
walkNode(children.valueSchema, path, visitors, visited);
// Record — descend with a record path element
walkNode(
children.valueSchema,
[...path, { type: 'record' }],
visitors,
visited,
);
break;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,17 @@ describe('walkSchemaStructure — simple schemas', () => {
// ---------------------------------------------------------------------------

describe('walkSchemaStructure — arrays', () => {
it('walks plain array elements without adding a path element', () => {
it('walks plain array elements with an array path element', () => {
const schema = z.object({ tags: z.array(z.string()) });
const visitor = makeRecordingVisitor();
walkSchemaStructure(schema, [visitor]);

// The string inside the array should have the same path as the array field
// (no array marker in path — non-deterministic)
expect(visitor.calls).toContainEqual({
path: [{ type: 'object-key', key: 'tags' }],
type: 'array',
});
expect(visitor.calls).toContainEqual({
path: [{ type: 'object-key', key: 'tags' }],
path: [{ type: 'object-key', key: 'tags' }, { type: 'array' }],
type: 'string',
});
});
Expand Down Expand Up @@ -268,20 +266,19 @@ describe('walkSchemaStructure — tuples', () => {
// ---------------------------------------------------------------------------

describe('walkSchemaStructure — records', () => {
it('walks record values without a path element', () => {
it('walks record values with a record path element', () => {
const schema = z.object({
data: z.record(z.string(), z.number()),
});
const visitor = makeRecordingVisitor();
walkSchemaStructure(schema, [visitor]);

// Record value schema visited at the same path as the record field
expect(visitor.calls).toContainEqual({
path: [{ type: 'object-key', key: 'data' }],
type: 'record',
});
expect(visitor.calls).toContainEqual({
path: [{ type: 'object-key', key: 'data' }],
path: [{ type: 'object-key', key: 'data' }, { type: 'record' }],
type: 'number',
});
});
Expand Down Expand Up @@ -436,13 +433,10 @@ describe('walkSchemaStructure — entity detection via collectEntityMetadata', (

const childMeta = map.get('du-child');
expect(childMeta).toBeDefined();
// Child is inside branch 'a' of the discriminated union array
// Child is inside branch 'a' — the leading discriminated-union-array
// element is stripped because it describes the parent's array branch,
// not the path from the parent entity to the child.
expect(childMeta?.relativePath).toEqual([
{
type: 'discriminated-union-array',
discriminatorKey: 'type',
value: 'a',
},
{ type: 'object-key', key: 'items' },
]);
expect(childMeta?.parentEntityTypeName).toBe('du-parent');
Expand Down
164 changes: 164 additions & 0 deletions packages/project-builder-lib/src/tools/assign-entity-ids.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest';

import {
createTestFeature,
createTestModel,
createTestScalarField,
} from '#src/testing/definition-helpers.test-helper.js';
import { createTestProjectDefinitionContainer } from '#src/testing/project-definition-container.test-helper.js';

import { assignEntityIds } from './assign-entity-ids.js';

describe('assignEntityIds', () => {
const feature = createTestFeature({ name: 'core' });
const model = createTestModel({
name: 'User',
featureRef: feature.name,
model: {
fields: [
createTestScalarField({
name: 'id',
type: 'uuid',
options: { genUuid: true },
}),
createTestScalarField({ name: 'email', type: 'string' }),
],
primaryKeyFieldRefs: ['id'],
},
});
const container = createTestProjectDefinitionContainer({
features: [feature],
models: [model],
});

const modelMetadata = container
.toEntityServiceContext()
.entityTypeMap.get('model');
if (!modelMetadata) {
throw new Error('Expected model entity type metadata');
}

it('replaces user-provided IDs with generated IDs', () => {
const entityData = {
id: 'user-provided-id',
name: 'Post',
featureRef: 'core',
model: {
fields: [
{
id: 'user-field-id',
name: 'title',
type: 'string',
isOptional: false,
options: { default: '' },
},
],
primaryKeyFieldRefs: ['title'],
},
service: {
create: { enabled: false },
update: { enabled: false },
delete: { enabled: false },
transformers: [],
},
};

const result = assignEntityIds(modelMetadata.elementSchema, entityData);

// Top-level entity ID should be replaced
expect(result.id).not.toBe('user-provided-id');
expect(result.id).toMatch(/^model:/);

// Nested field ID should also be replaced
expect(result.model.fields[0].id).not.toBe('user-field-id');
expect(result.model.fields[0].id).toMatch(/^model-scalar-field:/);

// Non-ID data should be preserved
expect(result.name).toBe('Post');
expect(result.model.fields[0].name).toBe('title');
});

it('preserves IDs when isExistingId returns true', () => {
const existingModelId = model.id;
const existingFieldId = model.model.fields[0].id;

const entityData = {
id: existingModelId,
name: 'User',
featureRef: 'core',
model: {
fields: [
{
id: existingFieldId,
name: 'id',
type: 'uuid',
isOptional: false,
options: { genUuid: true },
},
{
id: 'brand-new-field',
name: 'name',
type: 'string',
isOptional: false,
options: { default: '' },
},
],
primaryKeyFieldRefs: ['id'],
},
service: {
create: { enabled: false },
update: { enabled: false },
delete: { enabled: false },
transformers: [],
},
};

const existingIds = new Set([existingModelId, existingFieldId]);
const result = assignEntityIds(modelMetadata.elementSchema, entityData, {
isExistingId: (id) => existingIds.has(id),
});

// Existing IDs should be preserved
expect(result.id).toBe(existingModelId);
expect(result.model.fields[0].id).toBe(existingFieldId);

// New field ID should be replaced
expect(result.model.fields[1].id).not.toBe('brand-new-field');
expect(result.model.fields[1].id).toMatch(/^model-scalar-field:/);
});

it('assigns IDs when entity has no ID', () => {
const entityData = {
name: 'Tag',
featureRef: 'core',
model: {
fields: [
{
name: 'id',
type: 'uuid',
isOptional: false,
options: { genUuid: true },
},
],
primaryKeyFieldRefs: ['id'],
},
service: {
create: { enabled: false },
update: { enabled: false },
delete: { enabled: false },
transformers: [],
},
};

const result = assignEntityIds(
modelMetadata.elementSchema,
entityData,
) as typeof entityData & {
id: string;
model: { fields: { id: string }[] };
};

expect(result.id).toMatch(/^model:/);
expect(result.model.fields[0].id).toMatch(/^model-scalar-field:/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ function navigateToEntityArrayFromSchemaPath(
path.push(match);
break;
}
case 'array': {
throw new Error(
`Cannot navigate through plain array at path "${path.join('.')}": non-deterministic structure`,
);
}
case 'record': {
throw new Error(
`Cannot navigate through record at path "${path.join('.')}": non-deterministic structure`,
);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ export function collectEntityMetadata(schema: z.ZodType): EntityTypeMap {
}

parentEntityTypeName = parentType.name;
relativePath = ctx.path.slice(parentEntry.pathAtEntity.length);
// Strip the first element — it is always an array or
// discriminated-union-array that describes how to enter the parent's
// array, not how to navigate from the parent entity to this child.
relativePath = ctx.path.slice(parentEntry.pathAtEntity.length + 1);
} else {
// Top-level entity: relative path is the full path
relativePath = ctx.path;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ import type {
ProjectDefinitionInput,
SchemaParserContext,
} from '@baseplate-dev/project-builder-lib';
import type z from 'zod';

import { createTestProjectDefinitionContainer } from '@baseplate-dev/project-builder-lib/testing';
import { createConsoleLogger } from '@baseplate-dev/sync';

import type { ServiceActionContext } from '#src/actions/types.js';
import type {
ServiceAction,
ServiceActionContext,
} from '#src/actions/types.js';

import type { EntityServiceContextResult } from '../definition/load-entity-service-context.js';

Expand Down Expand Up @@ -58,3 +62,20 @@ export function createTestEntityServiceContext(
const entityContext = container.toEntityServiceContext();
return { entityContext, container, parserContext: container.parserContext };
}

/**
* Invokes a service action for testing, validating input/output schemas
* but skipping CLI output formatting.
*/
export async function invokeServiceActionForTest<
TInputType extends z.ZodType,
TOutputType extends z.ZodType,
>(
action: ServiceAction<TInputType, TOutputType>,
input: z.input<TInputType>,
context: ServiceActionContext,
): Promise<z.output<TOutputType>> {
const parsedInput = action.inputSchema.parse(input);
const result = await action.handler(parsedInput, context);
return action.outputSchema.parse(result);
}
Loading
Loading