mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 14:57: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
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