feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run

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:
Anthony Ettinger 2026-07-28 05:10:33 -07:00 committed by GitHub
parent 296775e003
commit da5f6f8381
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 6939 additions and 23 deletions

View 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;
}
}

View 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.`
}
}
]
})
);
}

View file

@ -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;
}

View 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"]));
});
});