/api/v1/technologies is a full CRUD with the panel's own rules: name and slug are plain strings, the prose is translatable, `radar_ring: null` takes one off the radar, and TechnologyPolicy still refuses to delete one a project, member or call uses. The MCP server picks the tools up from openapi.yaml on its own. A logo is an SVG, so POST /v1/media now accepts one — sanitized with enshrined/svg-sanitize (scripts, event handlers and remote references removed), and served by Caddy under a Content-Security-Policy that would stop a script regardless. `for` (pages or technologies) says what an upload is for, which picks the directory the panel uses for it and the permission it takes. Creating a technology without a category answered 500: the column's default never reached the model, so the response read a null enum. The controller now states the default, as the panel does. Beside a file, the MCP upload tool no longer demands the JSON body's `url`: the body then carries the upload's other fields, such as `for`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
4.3 KiB
JavaScript
106 lines
4.3 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { test } from 'node:test';
|
|
import { parse } from 'yaml';
|
|
import { buildTools } from '../src/tools.mjs';
|
|
|
|
// The document in this checkout: the one the application serves.
|
|
const spec = parse(await readFile(new URL('../../public/openapi.yaml', import.meta.url), 'utf8'));
|
|
const tools = buildTools(spec);
|
|
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
|
|
test('names follow the Laravel routes', () => {
|
|
const expected = {
|
|
public_site: 'GET /public/v1/site',
|
|
public_good_first_issues: 'GET /public/v1/good-first-issues',
|
|
public_projects_index: 'GET /public/v1/projects',
|
|
public_projects_show: 'GET /public/v1/projects/{slug}',
|
|
public_clients: 'GET /public/v1/clients',
|
|
admin_me: 'GET /v1/me',
|
|
admin_pages_index: 'GET /v1/pages',
|
|
admin_pages_store: 'POST /v1/pages',
|
|
admin_pages_update: 'PATCH /v1/pages/{page}',
|
|
admin_pages_destroy: 'DELETE /v1/pages/{page}',
|
|
admin_pages_restore: 'POST /v1/pages/{page}/restore',
|
|
admin_roles_catalogue: 'GET /v1/roles/catalogue',
|
|
admin_technologies_index: 'GET /v1/technologies',
|
|
admin_technologies_store: 'POST /v1/technologies',
|
|
admin_technologies_update: 'PATCH /v1/technologies/{technology}',
|
|
admin_media_store: 'POST /v1/media',
|
|
};
|
|
|
|
for (const [name, route] of Object.entries(expected)) {
|
|
const tool = byName.get(name);
|
|
assert.ok(tool, `missing ${name}`);
|
|
assert.equal(`${tool.operation.method} ${tool.operation.path}`, route);
|
|
}
|
|
});
|
|
|
|
test('names are valid MCP tool names', () => {
|
|
for (const { name } of tools) assert.match(name, /^[a-z0-9_]{1,64}$/);
|
|
});
|
|
|
|
test('leaves out what is not for an operator', () => {
|
|
const routes = tools.map(({ operation }) => `${operation.method} ${operation.path}`);
|
|
|
|
assert.ok(!routes.includes('POST /v1/auth/token'));
|
|
assert.ok(!routes.some((route) => route.includes('/telemetry/')));
|
|
assert.ok(!routes.includes('POST /public/v1/contact'));
|
|
assert.ok(!routes.includes('POST /public/v1/calls/{slug}/apply'));
|
|
});
|
|
|
|
test('every other documented operation is a tool', () => {
|
|
const documented = Object.values(spec.paths)
|
|
.flatMap((item) => ['get', 'post', 'put', 'patch', 'delete'].filter((m) => item[m])).length;
|
|
|
|
// auth/token, two telemetry endpoints, contact and apply.
|
|
assert.equal(tools.length, documented - 5);
|
|
});
|
|
|
|
test('schemas are self-contained JSON Schema', () => {
|
|
for (const tool of tools) {
|
|
const json = JSON.stringify(tool.inputSchema);
|
|
|
|
assert.ok(!json.includes('"$ref"'), `${tool.name} still has a $ref`);
|
|
assert.ok(!json.includes('"nullable"'), `${tool.name} still has nullable`);
|
|
}
|
|
});
|
|
|
|
test('path parameters are required, headers are not offered', () => {
|
|
const show = byName.get('public_projects_show');
|
|
|
|
assert.deepEqual(show.inputSchema.required, ['slug']);
|
|
assert.ok(!('X-Preview-Token' in show.inputSchema.properties));
|
|
});
|
|
|
|
test('a create requires its fields, an update does not', () => {
|
|
assert.deepEqual(byName.get('admin_pages_store').inputSchema.properties.body.required, ['title', 'slug']);
|
|
assert.equal(byName.get('admin_pages_update').inputSchema.properties.body.required, undefined);
|
|
});
|
|
|
|
test('only the admin API is marked as needing a token', () => {
|
|
for (const tool of tools) {
|
|
assert.equal(tool.operation.requiresAuth, tool.name.startsWith('admin_'), tool.name);
|
|
}
|
|
});
|
|
|
|
test('over stdio an upload takes a local path, and the JSON body becomes optional', () => {
|
|
const local = buildTools(spec, { localFiles: true }).find((tool) => tool.name === 'admin_media_store');
|
|
|
|
assert.deepEqual(local.operation.files, ['file']);
|
|
assert.equal(local.inputSchema.properties.file_path.type, 'string');
|
|
assert.ok(local.inputSchema.properties.body.properties.url);
|
|
assert.ok(local.inputSchema.properties.body.properties.for);
|
|
assert.equal(local.inputSchema.required, undefined);
|
|
// `url` is the JSON alternative; beside a file it must not be demanded.
|
|
assert.equal(local.inputSchema.properties.body.required, undefined);
|
|
});
|
|
|
|
test('over HTTP there is no local path to offer: only the URL', () => {
|
|
const remote = byName.get('admin_media_store');
|
|
|
|
assert.deepEqual(remote.operation.files, []);
|
|
assert.ok(!('file_path' in remote.inputSchema.properties));
|
|
assert.deepEqual(remote.inputSchema.required, ['body']);
|
|
});
|