mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 06:47:28 +00:00
feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
Everything the two shipped PRD phases deferred, minus what is called out below.
Storage (Phase 2)
@logicsrc/openontology gains a SQLite/Turso adapter. It hydrates the read
model at open, serves reads synchronously — a query evaluator that awaits per
triple pattern is unusable — and buffers mutations as SQL that flush() writes
in one transaction. Versioned idempotent migrations; indexes over subject,
predicate, entity-valued object, status, both time axes, aliases, and external
ids; FTS5 for label/alias search. The append-only status log is replayed on
open, so retractions, supersessions, and merge redirects survive a reopen.
REST + SSE + OpenAPI (Phase 2)
16 paths under /api/ontologies in logicsrc-web, described at
/api/ontologies/openapi and referencing the published JSON Schemas rather
than restating them. No token is read-only; a curator token can apply; an
agent token can propose and cannot apply. Idempotency-Key on mutations,
revision ETags, 409 on a stale base revision, and an SSE stream that emits
the same event objects as the JSON endpoint.
MCP (Phase 2)
OpenOntology and OpenPRD surfaces on the standards server: spec/manifest/
schema/queries and PRD spec/index as resources, 11 ontology tools and 6 PRD
tools, 7 prompts. Read-only by default; OPENONTOLOGY_MCP_WRITABLE=1 buys
proposals, never applies — the denial is the shared policy layer, not a
second rule that could drift.
Interoperability (Phase 3)
RDF/Turtle export and import of the reified profile, plus the plain triple
for asserted relationships so a consumer wanting only the accepted graph gets
one. SHACL for 5 of 7 constraint kinds; `unique` and `query` are reported as
unmapped in both the return value and the generated Turtle, because a shape
that quietly means something narrower is worse than no shape.
Source adapters (Phase 3)
CSV, JSON, YAML, NDJSON, Markdown, generic JSON HTTP, and GitHub. All produce
PROPOSED change-set operations with source, evidence selector, run id, and
confidence attached; fetch is injected so ingestion is offline and testable.
Each declares its capabilities, so "nothing was deleted upstream" is never
confused with "this adapter cannot see deletions" — none of the seven can.
TUI + explorer
Keyboard-first panels (types, entities, claims, sources, queries, change
sets, validation, audit) as plain strings that survive SSH and 60 columns;
status is a glyph and a word, never colour alone; the key bar wraps rather
than truncating. Wired as `logicsrc ontology tui`. A read-only web explorer
at /openontology/explore with entity and claim views showing status, both
clocks, confidence, sources, evidence, and append-only history — plus an
/openprd page for the companion standard.
Bugs found and fixed while testing
- the API built a new engine per request, so `explain` could never find a
resultId from a prior request; engines are now cached per role
- the TUI status bar called engine.validateOntologyPackage(), appending a
package.validated event on every repaint; it now uses the pure validator
Verification: 76 new tests (527 total across the monorepo, all passing); full
build green; the libSQL adapter is exercised against real files, the API
through its route handlers, and MCP over an in-memory transport.
Not included: PWA review/approval write flows (they need an auth story this
deployment does not have), OWL/RDFS mappings, SPARQL/Cypher/Datalog query
adapters, and Phase 4 governed actions. The compatibility matrix marks those
"planned", not "supported".
Refs: prd/0001-add-logicsrc-openontology-spec.md
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
296775e003
commit
da5f6f8381
53 changed files with 6939 additions and 23 deletions
564
packages/logicsrc-mcp/src/openontology.ts
Normal file
564
packages/logicsrc-mcp/src/openontology.ts
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createOntologyEngine,
|
||||
exportJsonLd,
|
||||
exportTurtle,
|
||||
constraintsToShacl,
|
||||
loadOntologyPackage,
|
||||
readOnlyActor,
|
||||
proposerActor,
|
||||
renderReport,
|
||||
type OntologyEngine
|
||||
} from "@logicsrc/openontology";
|
||||
|
||||
/**
|
||||
* OpenOntology surface for the MCP server.
|
||||
*
|
||||
* Read tools are always available. Write tools exist but are capability-scoped
|
||||
* and default to *proposal* behaviour (R158): the server runs as a read-only
|
||||
* actor unless OPENONTOLOGY_MCP_WRITABLE is set, and even then an agent actor
|
||||
* can only propose — applying is denied by the policy layer, not by this file.
|
||||
*/
|
||||
|
||||
const SPEC = `# LogicSRC OpenOntology
|
||||
|
||||
An open contract for durable, source-backed domain knowledge shared by humans
|
||||
and AI agents. Five nouns: Type, Entity, Claim, Source, Change set.
|
||||
|
||||
- Claims are the canonical fact record and are append-only. A correction is a
|
||||
dispute, retraction, or supersession — never an edit.
|
||||
- Every claim carries status, confidence, valid time, recorded time, and the
|
||||
sources it rests on. Confidence is metadata, never permission.
|
||||
- Queries use a portable triple-pattern AST with asOf and per-status filtering,
|
||||
and every answer can be traced to the claims, evidence, and sources behind it.
|
||||
- Agents propose; humans apply. An agent holding every scope still cannot apply.
|
||||
|
||||
Full specification: https://logicsrc.com/docs/openontology`;
|
||||
|
||||
interface OntologyContext {
|
||||
engine: OntologyEngine | null;
|
||||
packageDir: string | null;
|
||||
error: string | null;
|
||||
writable: boolean;
|
||||
}
|
||||
|
||||
function loadContext(): OntologyContext {
|
||||
const dir = process.env.OPENONTOLOGY_PACKAGE ?? null;
|
||||
const writable = process.env.OPENONTOLOGY_MCP_WRITABLE === "1";
|
||||
|
||||
if (!dir) {
|
||||
return { engine: null, packageDir: null, writable, error: "OPENONTOLOGY_PACKAGE is not set" };
|
||||
}
|
||||
const path = resolve(dir);
|
||||
if (!existsSync(path)) {
|
||||
return { engine: null, packageDir: path, writable, error: `${path} does not exist` };
|
||||
}
|
||||
|
||||
try {
|
||||
const pkg = loadOntologyPackage(path);
|
||||
const actor = writable ? proposerActor("agent:mcp") : readOnlyActor("agent:mcp");
|
||||
return {
|
||||
engine: createOntologyEngine({ package: pkg, actor, client: "logicsrc-mcp" }),
|
||||
packageDir: path,
|
||||
writable,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
return { engine: null, packageDir: path, writable, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
function textResult(text: string) {
|
||||
return { content: [{ type: "text" as const, text }] };
|
||||
}
|
||||
|
||||
function errorResult(text: string) {
|
||||
return { content: [{ type: "text" as const, text }], isError: true };
|
||||
}
|
||||
|
||||
export function registerOpenOntology(server: McpServer): void {
|
||||
const context = loadContext();
|
||||
|
||||
const requireEngine = (): OntologyEngine | null => context.engine;
|
||||
|
||||
const notConfigured = () =>
|
||||
errorResult(
|
||||
`No ontology package is loaded: ${context.error ?? "unknown reason"}. ` +
|
||||
"Set OPENONTOLOGY_PACKAGE to a directory containing openontology.yaml."
|
||||
);
|
||||
|
||||
/* ── resources ─────────────────────────────────────────────────────── */
|
||||
|
||||
server.registerResource(
|
||||
"openontology-spec",
|
||||
"logicsrc://openontology/spec",
|
||||
{
|
||||
title: "LogicSRC OpenOntology specification",
|
||||
description: "The OpenOntology model in brief: five nouns, claims, provenance, governance.",
|
||||
mimeType: "text/markdown"
|
||||
},
|
||||
async () => ({
|
||||
contents: [{ uri: "logicsrc://openontology/spec", mimeType: "text/markdown", text: SPEC }]
|
||||
})
|
||||
);
|
||||
|
||||
if (context.engine) {
|
||||
const engine = context.engine;
|
||||
const manifest = engine.getOntologyManifest();
|
||||
|
||||
server.registerResource(
|
||||
"openontology-manifest",
|
||||
`ontology://${manifest.id}/manifest`,
|
||||
{
|
||||
title: `${manifest.name} manifest`,
|
||||
description: "Package identity, namespace, licence, and maintainers.",
|
||||
mimeType: "application/json"
|
||||
},
|
||||
async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: `ontology://${manifest.id}/manifest`,
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(manifest, null, 2)
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
"openontology-schema",
|
||||
`ontology://${manifest.id}/schema`,
|
||||
{
|
||||
title: `${manifest.name} schema`,
|
||||
description: "Entity types, properties, relationship types, constraints, and saved queries.",
|
||||
mimeType: "application/json"
|
||||
},
|
||||
async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: `ontology://${manifest.id}/schema`,
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(engine.getOntologySchema(), null, 2)
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
"openontology-queries",
|
||||
`ontology://${manifest.id}/queries`,
|
||||
{
|
||||
title: `${manifest.name} saved queries`,
|
||||
description: "The questions this ontology already knows how to answer.",
|
||||
mimeType: "application/json"
|
||||
},
|
||||
async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: `ontology://${manifest.id}/queries`,
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(engine.getOntologySchema().queries, null, 2)
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/* ── read tools ────────────────────────────────────────────────────── */
|
||||
|
||||
server.registerTool(
|
||||
"ontology_status",
|
||||
{
|
||||
title: "OpenOntology status",
|
||||
description: "Reports which ontology package this server has loaded and whether writes are enabled.",
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async () =>
|
||||
textResult(
|
||||
JSON.stringify(
|
||||
{
|
||||
loaded: Boolean(context.engine),
|
||||
packageDir: context.packageDir,
|
||||
error: context.error,
|
||||
writable: context.writable,
|
||||
note: "Write tools propose change sets; applying is denied to agent actors by policy."
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"ontology_validate",
|
||||
{
|
||||
title: "Validate the loaded ontology package",
|
||||
description: "Runs schema, graph, provenance, policy, and constraint validation.",
|
||||
inputSchema: { strict: z.boolean().optional().describe("Treat unknown types and predicates as errors.") },
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ strict }) => {
|
||||
const engine = requireEngine();
|
||||
if (!engine) return notConfigured();
|
||||
const report = engine.validateOntologyPackage({ strict: strict === true });
|
||||
return textResult(renderReport(report, "markdown"));
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"ontology_get_entity",
|
||||
{
|
||||
title: "Get an entity",
|
||||
description: "Fetches one entity by id, following merge redirects.",
|
||||
inputSchema: { id: z.string().describe("Entity id, e.g. eth:person:avery-lindqvist") },
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ id }) => {
|
||||
const engine = requireEngine();
|
||||
if (!engine) return notConfigured();
|
||||
try {
|
||||
return textResult(JSON.stringify(engine.getEntity(id), null, 2));
|
||||
} catch (error) {
|
||||
return errorResult((error as Error).message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"ontology_find_entities",
|
||||
{
|
||||
title: "Find entities",
|
||||
description: "Ranked candidate matches with the evidence for each — never a silent single match.",
|
||||
inputSchema: {
|
||||
text: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
limit: z.number().int().positive().max(100).optional()
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ text, type, limit }) => {
|
||||
const engine = requireEngine();
|
||||
if (!engine) return notConfigured();
|
||||
const matches = engine.findEntities({ text, type, limit });
|
||||
return textResult(
|
||||
JSON.stringify(
|
||||
matches.map((match) => ({
|
||||
id: match.entity.id,
|
||||
name: match.entity.canonicalName,
|
||||
type: match.entity.type,
|
||||
score: match.score,
|
||||
matchedOn: match.matchedOn,
|
||||
evidence: match.evidence
|
||||
})),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"ontology_query",
|
||||
{
|
||||
title: "Run a portable query",
|
||||
description:
|
||||
"Runs a saved query by id, or an ad-hoc triple-pattern query. Results include the claim ids behind each row.",
|
||||
inputSchema: {
|
||||
savedQuery: z.string().optional().describe("Saved query id."),
|
||||
query: z.string().optional().describe("JSON query body with match/where/select/include."),
|
||||
asOf: z.string().optional().describe("ISO instant to evaluate domain valid time against."),
|
||||
limit: z.number().int().positive().max(500).optional()
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ savedQuery, query, asOf, limit }) => {
|
||||
const engine = requireEngine();
|
||||
if (!engine) return notConfigured();
|
||||
if (!savedQuery && !query) return errorResult("Provide savedQuery or query.");
|
||||
|
||||
try {
|
||||
const body = savedQuery
|
||||
? savedQuery
|
||||
: ({ ...(JSON.parse(query as string) as Record<string, unknown>) } as never);
|
||||
const result = engine.queryOntology(body as never);
|
||||
const rows = result.rows.slice(0, limit ?? 50);
|
||||
const manifest = engine.getOntologyManifest();
|
||||
|
||||
// R160: factual answers carry the ontology version and claim ids.
|
||||
return textResult(
|
||||
JSON.stringify(
|
||||
{
|
||||
ontology: `${manifest.id}@${manifest.version}`,
|
||||
resultId: result.id,
|
||||
columns: result.columns,
|
||||
claimStatus: result.explanation.claimStatus,
|
||||
asOf: asOf ?? result.explanation.asOf ?? null,
|
||||
rows: rows.map((row) => ({ ...row.bindings, claims: row.claims })),
|
||||
truncated: result.explanation.truncated || rows.length < result.rows.length
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
return errorResult((error as Error).message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"ontology_explain",
|
||||
{
|
||||
title: "Explain an answer",
|
||||
description: "Traces one result row to its claims, evidence, sources, and status history.",
|
||||
inputSchema: {
|
||||
resultId: z.string().describe("resultId returned by ontology_query."),
|
||||
row: z.number().int().nonnegative().optional()
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ resultId, row }) => {
|
||||
const engine = requireEngine();
|
||||
if (!engine) return notConfigured();
|
||||
try {
|
||||
return textResult(JSON.stringify(engine.explainOntologyResult(resultId, row ?? 0), null, 2));
|
||||
} catch (error) {
|
||||
return errorResult((error as Error).message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"ontology_export",
|
||||
{
|
||||
title: "Export the ontology",
|
||||
description: "Exports as JSON-LD, RDF/Turtle, or SHACL shapes, reporting anything the format cannot carry.",
|
||||
inputSchema: { format: z.enum(["jsonld", "turtle", "shacl"]) },
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ format }) => {
|
||||
const engine = requireEngine();
|
||||
if (!engine) return notConfigured();
|
||||
const pkg = engine.buildOntologyPackage();
|
||||
|
||||
if (format === "turtle") {
|
||||
const exported = exportTurtle(pkg);
|
||||
return textResult(
|
||||
`${exported.turtle}\n# lossy: ${exported.lossy.length} object(s) carry fields Turtle cannot express`
|
||||
);
|
||||
}
|
||||
if (format === "shacl") {
|
||||
const exported = constraintsToShacl(pkg);
|
||||
return textResult(exported.turtle);
|
||||
}
|
||||
const exported = exportJsonLd(pkg);
|
||||
return textResult(
|
||||
JSON.stringify({ document: exported.document, lossy: exported.lossy }, null, 2)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
/* ── write tools (proposal-only) ───────────────────────────────────── */
|
||||
|
||||
server.registerTool(
|
||||
"ontology_propose_claim",
|
||||
{
|
||||
title: "Propose a claim",
|
||||
description:
|
||||
"Creates a PROPOSED change set asserting one claim. Never applies it — a human with write scope does that.",
|
||||
inputSchema: {
|
||||
subject: z.string(),
|
||||
predicate: z.string(),
|
||||
objectEntity: z.string().optional(),
|
||||
objectValue: z.string().optional(),
|
||||
source: z.string().optional().describe("Source id backing the claim."),
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
runId: z.string().optional(),
|
||||
rationale: z.string().optional()
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
|
||||
},
|
||||
async (input) => {
|
||||
const engine = requireEngine();
|
||||
if (!engine) return notConfigured();
|
||||
if (!input.objectEntity && input.objectValue === undefined) {
|
||||
return errorResult("Provide objectEntity or objectValue.");
|
||||
}
|
||||
|
||||
try {
|
||||
const changeSet = engine.createOntologyChangeSet({
|
||||
title: `${input.subject} ${input.predicate} ${input.objectEntity ?? input.objectValue}`,
|
||||
rationale: input.rationale,
|
||||
runId: input.runId,
|
||||
operations: [
|
||||
{
|
||||
op: "assert-claim",
|
||||
value: {
|
||||
subject: input.subject,
|
||||
predicate: input.predicate,
|
||||
object: input.objectEntity ? { entity: input.objectEntity } : { value: input.objectValue },
|
||||
...(input.source ? { sources: [input.source] } : {}),
|
||||
...(input.confidence !== undefined ? { confidence: input.confidence } : {})
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
return textResult(
|
||||
JSON.stringify(
|
||||
{ changeSet: changeSet.id, status: changeSet.status, requiredApprovals: changeSet.requiredApprovals },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
return errorResult((error as Error).message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"ontology_create_changeset",
|
||||
{
|
||||
title: "Create a change set",
|
||||
description: "Creates a PROPOSED change set from a JSON array of operations.",
|
||||
inputSchema: {
|
||||
title: z.string(),
|
||||
operations: z.string().describe("JSON array of change-set operations."),
|
||||
rationale: z.string().optional(),
|
||||
runId: z.string().optional()
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
|
||||
},
|
||||
async ({ title, operations, rationale, runId }) => {
|
||||
const engine = requireEngine();
|
||||
if (!engine) return notConfigured();
|
||||
try {
|
||||
const parsed = JSON.parse(operations) as never[];
|
||||
const changeSet = engine.createOntologyChangeSet({ title, rationale, runId, operations: parsed });
|
||||
const diff = engine.diffOntologyChangeSet(changeSet.id);
|
||||
return textResult(JSON.stringify({ changeSet: changeSet.id, status: changeSet.status, diff }, null, 2));
|
||||
} catch (error) {
|
||||
return errorResult((error as Error).message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"ontology_validate_changeset",
|
||||
{
|
||||
title: "Validate a change set",
|
||||
description: "Validates the package as it would look after a change set applies.",
|
||||
inputSchema: { changeSet: z.string() },
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ changeSet }) => {
|
||||
const engine = requireEngine();
|
||||
if (!engine) return notConfigured();
|
||||
try {
|
||||
return textResult(renderReport(engine.validateOntologyChangeSet(changeSet), "markdown"));
|
||||
} catch (error) {
|
||||
return errorResult((error as Error).message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"ontology_apply_changeset",
|
||||
{
|
||||
title: "Apply a change set",
|
||||
description:
|
||||
"Applies an approved change set. Requires write scope and approvals; agent actors are denied by policy.",
|
||||
inputSchema: { changeSet: z.string() },
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ changeSet }) => {
|
||||
const engine = requireEngine();
|
||||
if (!engine) return notConfigured();
|
||||
try {
|
||||
const applied = engine.applyOntologyChangeSet(changeSet);
|
||||
return textResult(
|
||||
JSON.stringify({ changeSet: applied.changeSet.id, revision: applied.revision }, null, 2)
|
||||
);
|
||||
} catch (error) {
|
||||
// The denial is the point: surface it verbatim.
|
||||
return errorResult(`Not applied: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/* ── prompts ───────────────────────────────────────────────────────── */
|
||||
|
||||
const prompts: Array<[string, string, string]> = [
|
||||
[
|
||||
"design_ontology",
|
||||
"Design an OpenOntology package for a domain",
|
||||
`Design a LogicSRC OpenOntology package for the domain the user describes.
|
||||
|
||||
Produce entity types, properties, and relationship types first — nouns before facts.
|
||||
For each relationship type declare from/to, cardinality, and whether it is temporal.
|
||||
Do not infer transitivity, symmetry, or inverses unless you declare them.
|
||||
Then propose 3-5 saved queries that answer the questions people actually ask.
|
||||
Return YAML matching the OpenOntology schemas.`
|
||||
],
|
||||
[
|
||||
"map_sources_to_claims",
|
||||
"Turn source material into proposed claims",
|
||||
`Read the source material and produce PROPOSED claims.
|
||||
|
||||
Every claim needs: subject, predicate, a typed object, a source id, and an evidence
|
||||
selector pointing at the exact location it came from. Set confidence honestly — it is
|
||||
metadata, not persuasion. Never invent an entity id you have not seen; propose a new
|
||||
entity explicitly instead. Output change-set operations, not applied state.`
|
||||
],
|
||||
[
|
||||
"resolve_entities",
|
||||
"Decide whether two entity records are the same thing",
|
||||
`Compare the candidate entity records.
|
||||
|
||||
List the evidence for and against them being the same thing. Weigh stable external ids
|
||||
above name similarity. Recommend merge, keep-separate, or needs-more-evidence — and say
|
||||
which. Remember that merging two different people is worse than keeping duplicates, and
|
||||
that a merge needs curator approval.`
|
||||
],
|
||||
[
|
||||
"review_ontology_changeset",
|
||||
"Review a proposed change set",
|
||||
`Review this OpenOntology change set as a curator.
|
||||
|
||||
Check: does each claim cite a source? Do the domain and range hold? Is the confidence
|
||||
justified by the evidence? Does any add-entity duplicate something already present?
|
||||
Are merges and retractions reversible and justified? Report per-operation accept/reject
|
||||
with reasons, then an overall recommendation.`
|
||||
],
|
||||
[
|
||||
"explain_ontology_answer",
|
||||
"Explain why the ontology returned an answer",
|
||||
`Explain this query result to someone who does not trust it yet.
|
||||
|
||||
Walk from the answer to the claims that produced it, then to the evidence and sources.
|
||||
State the claim statuses included, the asOf time, and any filters applied. Name what is
|
||||
uncertain — low confidence, a single source, a stale source, a dispute — rather than
|
||||
presenting the row as settled fact.`
|
||||
]
|
||||
];
|
||||
|
||||
for (const [name, title, text] of prompts) {
|
||||
server.registerPrompt(
|
||||
name,
|
||||
{ title, description: title },
|
||||
async () => ({ messages: [{ role: "user" as const, content: { type: "text" as const, text } }] })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the packaged spec doc when it is available on disk. */
|
||||
export function readSpecDoc(path: string): string | null {
|
||||
try {
|
||||
return existsSync(path) ? readFileSync(path, "utf8") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
278
packages/logicsrc-mcp/src/openprd.ts
Normal file
278
packages/logicsrc-mcp/src/openprd.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
findPrd,
|
||||
loadPrdCollection,
|
||||
nextPrdNumber,
|
||||
nextStatuses,
|
||||
prdToTasks,
|
||||
renderDocument,
|
||||
renderIndex,
|
||||
renderReport,
|
||||
summarize,
|
||||
validatePrdCollection,
|
||||
validateTasks,
|
||||
type PrdCollection
|
||||
} from "@logicsrc/openprd";
|
||||
|
||||
/**
|
||||
* OpenPRD surface for the MCP server.
|
||||
*
|
||||
* Everything here is read-only. Creating or moving a PRD writes to a repo, and
|
||||
* that belongs to the CLI where a human sees the diff — not to a tool an agent
|
||||
* can call unattended.
|
||||
*/
|
||||
|
||||
const SPEC = `# OpenPRD
|
||||
|
||||
A lightweight standard for product requirements documents. A repo keeps a
|
||||
numbered, committed collection under prd/, one Markdown file each.
|
||||
|
||||
- One file per PRD: prd/<id>-<slug>.md, four-digit ids, no gaps, 0000 reserved
|
||||
for the template.
|
||||
- Front-matter carries openprd, id, title, status, authors, and optional repo,
|
||||
dates, discussion, implementation, tags, supersedes, superseded-by.
|
||||
- The body has eight required sections in order: Problem, Goals, Non-Goals,
|
||||
Users, Requirements, UX Notes, Success Metrics, Risks & Open Questions.
|
||||
- Requirements are numbered R1, R2, … each tagged [P0], [P1], or [P2].
|
||||
- Lifecycle: Draft → Review → Accepted → Final, or Rejected / Withdrawn /
|
||||
Superseded. Status lives in front-matter and is the source of truth.
|
||||
|
||||
Full specification: https://logicsrc.com/docs/openprd`;
|
||||
|
||||
function textResult(text: string) {
|
||||
return { content: [{ type: "text" as const, text }] };
|
||||
}
|
||||
|
||||
function errorResult(text: string) {
|
||||
return { content: [{ type: "text" as const, text }], isError: true };
|
||||
}
|
||||
|
||||
function openCollection(): { collection: PrdCollection | null; dir: string | null; error: string | null } {
|
||||
const dir = process.env.OPENPRD_DIR ?? "./prd";
|
||||
const path = resolve(dir);
|
||||
if (!existsSync(path)) {
|
||||
return { collection: null, dir: path, error: `${path} does not exist; set OPENPRD_DIR` };
|
||||
}
|
||||
try {
|
||||
return { collection: loadPrdCollection(path), dir: path, error: null };
|
||||
} catch (error) {
|
||||
return { collection: null, dir: path, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export function registerOpenPrd(server: McpServer): void {
|
||||
server.registerResource(
|
||||
"openprd-spec",
|
||||
"logicsrc://openprd/spec",
|
||||
{
|
||||
title: "OpenPRD specification",
|
||||
description: "Numbered PRDs: layout, front-matter, the eight sections, and the lifecycle.",
|
||||
mimeType: "text/markdown"
|
||||
},
|
||||
async () => ({
|
||||
contents: [{ uri: "logicsrc://openprd/spec", mimeType: "text/markdown", text: SPEC }]
|
||||
})
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
"openprd-index",
|
||||
"prd://index",
|
||||
{
|
||||
title: "PRD index",
|
||||
description: "The current collection index, generated from the PRDs on disk.",
|
||||
mimeType: "text/markdown"
|
||||
},
|
||||
async () => {
|
||||
const { collection, error } = openCollection();
|
||||
const text = collection ? renderIndex(collection) : `No PRD collection: ${error}`;
|
||||
return { contents: [{ uri: "prd://index", mimeType: "text/markdown", text }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"prd_list",
|
||||
{
|
||||
title: "List PRDs",
|
||||
description: "Lists the PRDs in the collection with id, title, status, tags, and requirement count.",
|
||||
inputSchema: { status: z.string().optional().describe("Comma-separated statuses to include.") },
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ status }) => {
|
||||
const { collection, error } = openCollection();
|
||||
if (!collection) return errorResult(error ?? "no collection");
|
||||
const wanted = status?.split(",").map((value) => value.trim());
|
||||
const rows = collection.documents
|
||||
.map(summarize)
|
||||
.filter((row) => !wanted || wanted.includes(row.status));
|
||||
return textResult(JSON.stringify(rows, null, 2));
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"prd_show",
|
||||
{
|
||||
title: "Show a PRD",
|
||||
description: "Shows one PRD's front-matter, sections, and parsed requirements.",
|
||||
inputSchema: {
|
||||
ref: z.string().describe("Id, number, slug, or filename."),
|
||||
format: z.enum(["text", "json", "markdown"]).optional()
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ ref, format }) => {
|
||||
const { collection, error } = openCollection();
|
||||
if (!collection) return errorResult(error ?? "no collection");
|
||||
const doc = findPrd(collection, ref);
|
||||
if (!doc) return errorResult(`No PRD matching "${ref}"`);
|
||||
return textResult(renderDocument(doc, format ?? "text"));
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"prd_validate",
|
||||
{
|
||||
title: "Validate the PRD collection",
|
||||
description:
|
||||
"Checks conformance — filename, front-matter, id match, the eight sections in order — plus collection rules.",
|
||||
inputSchema: { strict: z.boolean().optional() },
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ strict }) => {
|
||||
const { collection, error } = openCollection();
|
||||
if (!collection) return errorResult(error ?? "no collection");
|
||||
const report = validatePrdCollection(collection, {
|
||||
strict: strict === true,
|
||||
expectedIndex: renderIndex(collection)
|
||||
});
|
||||
return textResult(renderReport(report, "markdown"));
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"prd_next_id",
|
||||
{
|
||||
title: "Next free PRD id",
|
||||
description: "Returns the next four-digit id. Numbers are assigned at creation, never reserved.",
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async () => {
|
||||
const { collection, error } = openCollection();
|
||||
if (!collection) return errorResult(error ?? "no collection");
|
||||
return textResult(nextPrdNumber(collection));
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"prd_next_statuses",
|
||||
{
|
||||
title: "Allowed lifecycle moves",
|
||||
description: "Given a PRD, lists the statuses it may legally move to next.",
|
||||
inputSchema: { ref: z.string() },
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ ref }) => {
|
||||
const { collection, error } = openCollection();
|
||||
if (!collection) return errorResult(error ?? "no collection");
|
||||
const doc = findPrd(collection, ref);
|
||||
if (!doc) return errorResult(`No PRD matching "${ref}"`);
|
||||
const allowed = nextStatuses(doc.frontMatter.status);
|
||||
return textResult(
|
||||
JSON.stringify(
|
||||
{
|
||||
id: doc.frontMatter.id,
|
||||
status: doc.frontMatter.status,
|
||||
allowedNext: allowed,
|
||||
terminal: allowed.length === 0
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"prd_tasks",
|
||||
{
|
||||
title: "Map requirements to LogicSRC tasks",
|
||||
description: "Turns each R# into one logicsrc.task document, validated before it is returned.",
|
||||
inputSchema: {
|
||||
ref: z.string(),
|
||||
priority: z.string().optional().describe("Comma-separated priorities, e.g. P0,P1"),
|
||||
creator: z.string().optional()
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false }
|
||||
},
|
||||
async ({ ref, priority, creator }) => {
|
||||
const { collection, error } = openCollection();
|
||||
if (!collection) return errorResult(error ?? "no collection");
|
||||
const doc = findPrd(collection, ref);
|
||||
if (!doc) return errorResult(`No PRD matching "${ref}"`);
|
||||
|
||||
const priorities = priority?.split(",").map((value) => value.trim()) as
|
||||
| Array<"P0" | "P1" | "P2">
|
||||
| undefined;
|
||||
const { tasks, skipped } = prdToTasks(doc, { creator, priorities });
|
||||
const problems = validateTasks(tasks);
|
||||
if (problems.length > 0) {
|
||||
return errorResult(`Generated tasks failed validation: ${JSON.stringify(problems, null, 2)}`);
|
||||
}
|
||||
return textResult(JSON.stringify({ tasks, skipped }, null, 2));
|
||||
}
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
"write_prd",
|
||||
{ title: "Draft an OpenPRD document", description: "Draft a conforming PRD for a product decision." },
|
||||
async () => ({
|
||||
messages: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `Draft an OpenPRD document for the change the user describes.
|
||||
|
||||
Front-matter: openprd "0.2", a four-digit id matching the filename, an imperative
|
||||
title starting with a verb, status Draft, and at least one author.
|
||||
|
||||
Then all eight sections, in this order, none omitted:
|
||||
Problem, Goals, Non-Goals, Users, Requirements, UX Notes, Success Metrics,
|
||||
Risks & Open Questions. A section may be a single line such as _None._
|
||||
|
||||
Requirements are numbered R1, R2, … contiguously, each tagged [P0], [P1], or [P2],
|
||||
one capability per line. Goals are outcomes, not features. Non-Goals bound the work.
|
||||
Risks & Open Questions must name the decisions still owed rather than pretending
|
||||
they are settled.`
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
"review_prd",
|
||||
{ title: "Review a PRD", description: "Review a PRD for shape, clarity, and honesty." },
|
||||
async () => ({
|
||||
messages: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `Review this PRD.
|
||||
|
||||
Check the shape first: all eight sections present and in order, requirements numbered
|
||||
contiguously with priority tags, front-matter complete.
|
||||
|
||||
Then the substance: are the Goals outcomes rather than features? Do the Non-Goals
|
||||
actually bound the work? Is every P0 requirement testable? Do the Success Metrics
|
||||
measure the Goals? Do the Risks name real decisions still owed, or is that section
|
||||
decoration? Say what you would change and why.`
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { assertSchemaKind, parseDocument, schemas, validate, type SchemaKind } from "@logicsrc/validators";
|
||||
import { registerOpenOntology } from "./openontology.js";
|
||||
import { registerOpenPrd } from "./openprd.js";
|
||||
|
||||
const docs = {
|
||||
"communication-accounts": `LogicSRC Communication Accounts defines shared contracts for connecting social and email identities, granting scoped human/agent/plugin access, evaluating policy gates, brokering credentials, and auditing every account action without exposing raw secrets.`,
|
||||
|
|
@ -25,7 +27,7 @@ export function createLogicSrcMcpServer() {
|
|||
tools: {},
|
||||
prompts: {}
|
||||
},
|
||||
instructions: "Use this server for LogicSRC standards, schema resources, validation, and draft object generation. Treat CommandBoard.run as a reference implementation, not the standards identity."
|
||||
instructions: "Use this server for LogicSRC standards, schema resources, validation, draft object generation, OpenOntology knowledge (entities, claims, provenance, governed change sets), and OpenPRD product requirements documents. Treat CommandBoard.run as a reference implementation, not the standards identity. Ontology and PRD write tools propose; they never apply."
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -151,6 +153,9 @@ export function createLogicSrcMcpServer() {
|
|||
})
|
||||
);
|
||||
|
||||
registerOpenOntology(server);
|
||||
registerOpenPrd(server);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
|
|
|
|||
226
packages/logicsrc-mcp/src/standards.test.ts
Normal file
226
packages/logicsrc-mcp/src/standards.test.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { initOntologyPackage } from "@logicsrc/openontology";
|
||||
import { createLogicSrcMcpServer } from "./server.js";
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const dirs: string[] = [];
|
||||
|
||||
let ontologyDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
ontologyDir = mkdtempSync(join(tmpdir(), "mcp-ontology-"));
|
||||
dirs.push(ontologyDir);
|
||||
initOntologyPackage(ontologyDir, { id: "test-ecosystem", now: "2026-07-26T00:00:00Z" });
|
||||
process.env.OPENONTOLOGY_PACKAGE = ontologyDir;
|
||||
process.env.OPENPRD_DIR = join(REPO, "prd");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.OPENONTOLOGY_PACKAGE;
|
||||
delete process.env.OPENPRD_DIR;
|
||||
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function connect() {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const server = createLogicSrcMcpServer();
|
||||
const client = new Client({ name: "test-client", version: "0.1.0" });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return client;
|
||||
}
|
||||
|
||||
function toolText(result: unknown): string {
|
||||
const content = (result as { content?: Array<{ type: string; text?: string }> }).content ?? [];
|
||||
return content.map((entry) => entry.text ?? "").join("\n");
|
||||
}
|
||||
|
||||
/** Resource contents are text-or-blob in the SDK types; these fixtures are text. */
|
||||
function resourceText(contents: unknown): string {
|
||||
const entry = (contents as Array<{ text?: string }>)[0];
|
||||
return entry?.text ?? "";
|
||||
}
|
||||
|
||||
function isError(result: unknown): boolean {
|
||||
return (result as { isError?: boolean }).isError === true;
|
||||
}
|
||||
|
||||
describe("MCP: OpenOntology", () => {
|
||||
it("exposes the spec, manifest, schema, and saved queries as resources", async () => {
|
||||
const client = await connect();
|
||||
const uris = (await client.listResources()).resources.map((resource) => resource.uri);
|
||||
expect(uris).toContain("logicsrc://openontology/spec");
|
||||
expect(uris).toContain("ontology://test-ecosystem/manifest");
|
||||
expect(uris).toContain("ontology://test-ecosystem/schema");
|
||||
expect(uris).toContain("ontology://test-ecosystem/queries");
|
||||
});
|
||||
|
||||
it("serves the ontology schema resource", async () => {
|
||||
const client = await connect();
|
||||
const resource = await client.readResource({ uri: "ontology://test-ecosystem/schema" });
|
||||
expect(resourceText(resource.contents)).toContain("worksOn");
|
||||
});
|
||||
|
||||
it("runs a query and returns claim ids with the ontology version (R160)", async () => {
|
||||
const client = await connect();
|
||||
const result = await client.callTool({ name: "ontology_query", arguments: { savedQuery: "contributors" } });
|
||||
const payload = JSON.parse(toolText(result)) as {
|
||||
ontology: string;
|
||||
rows: Array<{ claims: string[] }>;
|
||||
resultId: string;
|
||||
};
|
||||
expect(payload.ontology).toBe("test-ecosystem@0.1.0");
|
||||
expect(payload.rows.length).toBeGreaterThan(0);
|
||||
expect(payload.rows[0]!.claims.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("explains a result down to sources", async () => {
|
||||
const client = await connect();
|
||||
const query = await client.callTool({ name: "ontology_query", arguments: { savedQuery: "contributors" } });
|
||||
const { resultId } = JSON.parse(toolText(query)) as { resultId: string };
|
||||
|
||||
const explained = await client.callTool({
|
||||
name: "ontology_explain",
|
||||
arguments: { resultId, row: 0 }
|
||||
});
|
||||
const payload = JSON.parse(toolText(explained)) as { claims: Array<{ sources: unknown[] }> };
|
||||
expect(payload.claims[0]!.sources.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("validates the loaded package", async () => {
|
||||
const client = await connect();
|
||||
const result = await client.callTool({ name: "ontology_validate", arguments: { strict: true } });
|
||||
expect(toolText(result)).toContain("Validation passed");
|
||||
});
|
||||
|
||||
it("finds entities with ranked evidence rather than a silent match", async () => {
|
||||
const client = await connect();
|
||||
const result = await client.callTool({ name: "ontology_find_entities", arguments: { text: "Alice" } });
|
||||
const matches = JSON.parse(toolText(result)) as Array<{ id: string; matchedOn: string; score: number }>;
|
||||
expect(matches[0]!.id).toBe("test:person:alice");
|
||||
expect(matches[0]!.matchedOn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refuses to propose when the server is read-only (the default)", async () => {
|
||||
const client = await connect();
|
||||
const denied = await client.callTool({
|
||||
name: "ontology_propose_claim",
|
||||
arguments: {
|
||||
subject: "test:person:alice",
|
||||
predicate: "worksOn",
|
||||
objectEntity: "test:project:docs-portal",
|
||||
source: "test:source:repo"
|
||||
}
|
||||
});
|
||||
expect(isError(denied)).toBe(true);
|
||||
expect(toolText(denied)).toContain("ontology:claim:propose");
|
||||
});
|
||||
|
||||
it("proposes a change set when writes are enabled — and still cannot apply it", async () => {
|
||||
process.env.OPENONTOLOGY_MCP_WRITABLE = "1";
|
||||
try {
|
||||
const client = await connect();
|
||||
const proposed = await client.callTool({
|
||||
name: "ontology_propose_claim",
|
||||
arguments: {
|
||||
subject: "test:person:alice",
|
||||
predicate: "worksOn",
|
||||
objectEntity: "test:project:docs-portal",
|
||||
source: "test:source:repo",
|
||||
runId: "run_mcp_1"
|
||||
}
|
||||
});
|
||||
const payload = JSON.parse(toolText(proposed)) as { changeSet: string; status: string };
|
||||
expect(payload.status).toBe("proposed");
|
||||
|
||||
// Enabling writes buys proposals, not applies: the actor is an agent.
|
||||
const applied = await client.callTool({
|
||||
name: "ontology_apply_changeset",
|
||||
arguments: { changeSet: payload.changeSet }
|
||||
});
|
||||
expect(isError(applied)).toBe(true);
|
||||
expect(toolText(applied)).toMatch(/never apply directly/);
|
||||
} finally {
|
||||
delete process.env.OPENONTOLOGY_MCP_WRITABLE;
|
||||
}
|
||||
});
|
||||
|
||||
it("exports Turtle and SHACL", async () => {
|
||||
const client = await connect();
|
||||
const turtle = await client.callTool({ name: "ontology_export", arguments: { format: "turtle" } });
|
||||
expect(toolText(turtle)).toContain("@prefix oo:");
|
||||
|
||||
const shacl = await client.callTool({ name: "ontology_export", arguments: { format: "shacl" } });
|
||||
expect(toolText(shacl)).toContain("sh:NodeShape");
|
||||
});
|
||||
|
||||
it("registers the ontology prompts", async () => {
|
||||
const client = await connect();
|
||||
const names = (await client.listPrompts()).prompts.map((prompt) => prompt.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
"design_ontology",
|
||||
"map_sources_to_claims",
|
||||
"resolve_entities",
|
||||
"review_ontology_changeset",
|
||||
"explain_ontology_answer"
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP: OpenPRD", () => {
|
||||
it("exposes the spec and the generated index", async () => {
|
||||
const client = await connect();
|
||||
const uris = (await client.listResources()).resources.map((resource) => resource.uri);
|
||||
expect(uris).toContain("logicsrc://openprd/spec");
|
||||
expect(uris).toContain("prd://index");
|
||||
|
||||
const index = await client.readResource({ uri: "prd://index" });
|
||||
expect(resourceText(index.contents)).toContain("| ID | Title | Status | Tags |");
|
||||
});
|
||||
|
||||
it("lists this repo's PRDs", async () => {
|
||||
const client = await connect();
|
||||
const result = await client.callTool({ name: "prd_list", arguments: {} });
|
||||
const rows = JSON.parse(toolText(result)) as Array<{ id: string; title: string; requirements: number }>;
|
||||
expect(rows[0]!.id).toBe("0001");
|
||||
expect(rows[0]!.requirements).toBe(210);
|
||||
});
|
||||
|
||||
it("validates the collection", async () => {
|
||||
const client = await connect();
|
||||
const result = await client.callTool({ name: "prd_validate", arguments: {} });
|
||||
expect(toolText(result)).toContain("validation passed");
|
||||
});
|
||||
|
||||
it("reports the next free id and the allowed lifecycle moves", async () => {
|
||||
const client = await connect();
|
||||
expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0002");
|
||||
|
||||
const moves = await client.callTool({ name: "prd_next_statuses", arguments: { ref: "0001" } });
|
||||
const payload = JSON.parse(toolText(moves)) as { status: string; allowedNext: string[] };
|
||||
expect(payload.status).toBe("Draft");
|
||||
expect(payload.allowedNext).toEqual(["Review", "Withdrawn"]);
|
||||
});
|
||||
|
||||
it("maps requirements to validated tasks", async () => {
|
||||
const client = await connect();
|
||||
const result = await client.callTool({ name: "prd_tasks", arguments: { ref: "0001", priority: "P0" } });
|
||||
const payload = JSON.parse(toolText(result)) as { tasks: Array<{ type: string }> };
|
||||
expect(payload.tasks.length).toBeGreaterThan(100);
|
||||
expect(payload.tasks[0]!.type).toBe("logicsrc.task");
|
||||
});
|
||||
|
||||
it("registers the PRD prompts", async () => {
|
||||
const client = await connect();
|
||||
const names = (await client.listPrompts()).prompts.map((prompt) => prompt.name);
|
||||
expect(names).toEqual(expect.arrayContaining(["write_prd", "review_prd"]));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue