Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ dist
*.log
.DS_Store
*.tgz

# Generated from upstream cookbook at build time (see packages/mcp/scripts/sync-cookbook.ts)
packages/mcp/src/best-practices.gen.ts
29 changes: 29 additions & 0 deletions packages/mcp/cookbook-descriptions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"exposed": {
"queries": {
"title": "GQ query best practices",
"description": "Read before authoring queries (via `read` or `change`). Covers .gq grammar, query name dispatch, parameter declaration, the PascalCase→lowerCamelCase edge casing trap, search functions requiring trailing `limit`, and the T12 non-nullable lint rule."
},
"data": {
"title": "Data ingest patterns",
"description": "Read before calling `ingest`. Covers mode selection (merge/append/overwrite), the branch→ingest→verify→merge loop for large/risky writes, embedding-staleness gotcha (mode:merge does not recompute embeddings), and change-vs-ingest decision matrix."
},
"schema": {
"title": "Schema authoring and evolution",
"description": "Read before calling `schema_apply`. Covers .pg grammar, decorators (@key, @unique, @embed, @rename_from), inline-only enums, the add-optional→backfill→tighten sequence for non-nullable additions, and why apply is main-only and rejects open feature branches."
},
"remote-ops": {
"title": "Remote-operation safety",
"description": "Read after any 504 or unexpected error. Covers the verify-after-write ritual (commits_list head before/after), retry-safety table by node kind (pointer types dedupe via @key; append-only types duplicate on retry), and the `sync_branch()` server-internal error vs. a tool."
},
"search": {
"title": "Vector and full-text search",
"description": "Read before using nearest/bm25/rrf. Covers the scope-first-rank-second pattern, the hardcoded embedding model (gemini-embedding-2-preview, Vector(3072)), and trailing-`limit` requirement."
}
},
"skipped": {
"aliases": "CLI-only — omnigraph.yaml + --alias mechanics, not actionable through the HTTP API the MCP wraps.",
"commands": "CLI-only — the omnigraph binary's command surface, not the HTTP API.",
"server-policy": "Server-deployment concerns (Cedar policy, auth tokens), out of scope for an MCP client."
}
}
13 changes: 9 additions & 4 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@modernrelay/omnigraph-mcp",
"version": "0.4.0",
"version": "0.4.1",
"description": "MCP server exposing an Omnigraph database to LLM clients (Tools + Resources, stdio transport).",
"license": "MIT",
"repository": {
Expand Down Expand Up @@ -31,11 +31,15 @@
"node": ">=22"
},
"scripts": {
"sync-cookbook": "tsx scripts/sync-cookbook.ts",
"prebuild": "pnpm run sync-cookbook",
"build": "tsup",
"pretypecheck": "pnpm run sync-cookbook",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
"clean": "rm -rf dist",
"prepublishOnly": "pnpm run build",
"test": "vitest run"
"pretest": "pnpm run sync-cookbook",
"test": "vitest run",
"clean": "rm -rf dist src/best-practices.gen.ts",
"prepublishOnly": "pnpm run build"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
Expand All @@ -45,6 +49,7 @@
"devDependencies": {
"@types/node": "^22.10.5",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^2"
},
Expand Down
198 changes: 198 additions & 0 deletions packages/mcp/scripts/sync-cookbook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Fetches the omnigraph-best-practices markdown references and bakes them
// into a generated TS module that the MCP server imports. The MCP exposes
// each file as a `omnigraph://best-practices/<topic>` resource so an LLM
// can pull the right reference on demand.
//
// Discovery is upstream-driven: we list the references/ directory on each
// run and require a matching curated entry in cookbook-descriptions.json
// for every file. If upstream adds a new reference without a matching
// description, the build fails loudly with a clear remediation message.
// If a description is stale (no longer matches upstream), the build also
// fails — we never ship descriptions for files that no longer exist.
//
// Why curated descriptions: the `description` is what the LLM reads at
// `resources/list` time to decide whether to pull a body. Auto-generated
// titles from filenames are too vague for that selection to be reliable.
// Hand-written guidance ("Read before authoring queries…") gives the LLM
// the cue it needs.
//
// Source of truth: ModernRelay/omnigraph-cookbooks @ main. The generated
// TS module is gitignored; every build/typecheck regenerates it. CI builds
// always fetch fresh; the published npm tarball ships the bundled JS with
// the markdown inlined as string constants.

import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = dirname(fileURLToPath(import.meta.url));
const PKG_ROOT = dirname(HERE);
const OUT = join(PKG_ROOT, 'src/best-practices.gen.ts');
const DESCRIPTIONS_PATH = join(PKG_ROOT, 'cookbook-descriptions.json');

const REPO = 'ModernRelay/omnigraph-cookbooks';
const REF = 'main';
const REF_DIR = 'skills/omnigraph-best-practices/references';

const LIST_URL = `https://api.github.com/repos/${REPO}/contents/${REF_DIR}?ref=${REF}`;
const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${REF}/${REF_DIR}`;

interface CuratedDescription {
title: string;
description: string;
}

interface DescriptionsConfig {
/** Files to expose as MCP resources, with LLM-facing descriptions. */
exposed: Record<string, CuratedDescription>;
/** Files intentionally not exposed; value is the reason. */
skipped: Record<string, string>;
}

interface GithubContentItem {
name: string;
type: 'file' | 'dir' | 'symlink' | 'submodule';
}

function constId(key: string): string {
// queries -> QUERIES_MD, remote-ops -> REMOTE_OPS_MD
return `${key.toUpperCase().replace(/-/g, '_')}_MD`;
}

async function listUpstream(): Promise<string[]> {
const res = await fetch(LIST_URL, {
headers: { Accept: 'application/vnd.github+json' },
});
if (!res.ok) {
throw new Error(`list ${LIST_URL}: ${res.status} ${res.statusText}`);
}
const items = (await res.json()) as GithubContentItem[];
return items
.filter((i) => i.type === 'file' && i.name.endsWith('.md'))
.map((i) => i.name.replace(/\.md$/, ''))
.sort();
}

async function fetchBody(key: string): Promise<string> {
const url = `${RAW_BASE}/${key}.md`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`fetch ${url}: ${res.status} ${res.statusText}`);
}
return await res.text();
}

const upstream = await listUpstream();
const config = JSON.parse(readFileSync(DESCRIPTIONS_PATH, 'utf8')) as DescriptionsConfig;

const exposedKeys = new Set(Object.keys(config.exposed));
const skippedKeys = new Set(Object.keys(config.skipped));
const knownKeys = new Set([...exposedKeys, ...skippedKeys]);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const remote = new Set(upstream);

const unaccounted = upstream.filter((k) => !knownKeys.has(k));
const staleExposed = [...exposedKeys].filter((k) => !remote.has(k));
const staleSkipped = [...skippedKeys].filter((k) => !remote.has(k));
// A file declared in both sections is a contradictory state: the union
// hides it from the unaccounted check, then `exposed` silently wins at
// fetch time and the `skipped` reason becomes dead config. Fail loud so
// the maintainer picks one.
const contradictory = [...exposedKeys].filter((k) => skippedKeys.has(k));

if (
unaccounted.length > 0 ||
staleExposed.length > 0 ||
staleSkipped.length > 0 ||
contradictory.length > 0
) {
const lines: string[] = ['cookbook-descriptions.json is out of sync with upstream:'];
if (unaccounted.length > 0) {
lines.push(
'',
'New files upstream without a curated description or explicit skip:',
...unaccounted.map((k) => ` + ${k}.md`),
'',
` → in ${DESCRIPTIONS_PATH}, either:`,
' - add to "exposed" with { title, description } if the file is',
' useful to an LLM operating through the MCP. The description is',
' what the LLM reads at resources/list time to decide whether',
' to pull the body; write it for that audience.',
' - or add to "skipped" with a one-line reason explaining why',
' it is not actionable via the HTTP API the MCP wraps.',
);
}
if (staleExposed.length > 0 || staleSkipped.length > 0) {
lines.push('', 'Entries that no longer exist upstream:');
for (const k of staleExposed) lines.push(` - exposed.${k} (file gone)`);
for (const k of staleSkipped) lines.push(` - skipped.${k} (file gone)`);
lines.push('', ` → remove these from ${DESCRIPTIONS_PATH}`);
}
if (contradictory.length > 0) {
lines.push(
'',
'Entries declared in BOTH "exposed" and "skipped":',
...contradictory.map((k) => ` ! ${k}`),
'',
` → in ${DESCRIPTIONS_PATH}, remove from one section.`,
);
}
throw new Error(lines.join('\n'));
}

const exposedKeysSorted = upstream.filter((k) => exposedKeys.has(k));
const fetched = await Promise.all(
exposedKeysSorted.map(async (key) => ({
key,
constId: constId(key),
...config.exposed[key]!,
body: await fetchBody(key),
})),
);

const exports = fetched
.map((f) => `export const ${f.constId} = ${JSON.stringify(f.body)};`)
.join('\n\n');

const indexLines = fetched
.map(
(f) =>
` { key: ${JSON.stringify(f.key)}, uri: ${JSON.stringify(
`omnigraph://best-practices/${f.key}`,
)}, title: ${JSON.stringify(f.title)}, description: ${JSON.stringify(
f.description,
)}, body: ${f.constId} },`,
)
.join('\n');

const out = `// AUTO-GENERATED by packages/mcp/scripts/sync-cookbook.ts. Do not edit by hand.
// Source: ${RAW_BASE}
//
// Discovery: every \`.md\` under the upstream references/ directory.
// Descriptions: packages/mcp/cookbook-descriptions.json (curated).
//
// Regenerated on every build/typecheck/test via the prebuild/pretypecheck/
// pretest hooks in packages/mcp/package.json. The committed file is
// .gitignored — what ships in the npm tarball is the bundled JS with these
// strings inlined.

${exports}

export interface CookbookEntry {
readonly key: string;
readonly uri: string;
readonly title: string;
readonly description: string;
readonly body: string;
}

export const COOKBOOK: readonly CookbookEntry[] = [
${indexLines}
] as const;
`;

mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, out);
const totalBytes = fetched.reduce((n, f) => n + f.body.length, 0);
console.log(
`wrote ${OUT} (${fetched.length} files, ${totalBytes} chars of markdown, ${out.length} chars total)`,
);
94 changes: 90 additions & 4 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,35 @@ import {
SERVER_VERSION as SDK_SERVER_VERSION,
} from '@modernrelay/omnigraph';
import { z } from 'zod';
import { COOKBOOK } from './best-practices.gen';

const INSTRUCTIONS = `Omnigraph is a versioned property graph. Reads are typed GQ queries; writes are server-orchestrated and branchable.

ALWAYS read \`omnigraph://schema\` (or call \`schema_get\`) FIRST, before any query, mutation, or ingest. Schema declares node/edge types, @key fields, non-nullable properties, edge directions, and casing. Writing without seeing the schema produces queries that lint-fail or silently corrupt data.

After schema, consult the matching best-practices resource for the task at hand:
- omnigraph://best-practices/queries — before .gq queries (read/change)
- omnigraph://best-practices/data — before ingest (mode selection, branch loop)
- omnigraph://best-practices/schema — before schema_apply
- omnigraph://best-practices/remote-ops — after any 504 or unexpected error
- omnigraph://best-practices/search — before nearest/bm25/rrf queries

Workflow norms (violating these breaks things or silently corrupts data):

1. .gq edges use lowerCamelCase even though the schema declares them PascalCase. No top-level \`mutation { }\` wrapper — every block is \`query name($p: T) { insert|update|delete ... }\`. Dispatch writes via \`change\`, not \`read\`.
2. Parameterize. Pass values via \`params\`, never interpolate into the query body. Declare typed params: \`query foo($slug: String) { ... }\`.
3. \`nearest\`, \`bm25\`, and \`rrf\` require a trailing \`limit N\` — they are ordering operators, not filters.
4. \`ingest mode: "merge"\` upserts by @key (idempotent — use this for at-least-once pipelines). \`"overwrite"\` truncates the branch. \`"append"\` fails on key collision.
5. Verify every write. \`commits_list\` head BEFORE and AFTER. If identical, the write did not land. 504s do not mean failure — the server may have committed after the proxy dropped the response.
6. Append-only types (Signal, Claim, Decision, Event, Interaction, Policy, Outcome, MarketingElement) duplicate on blind retry. Pointer types (Org, Person, Opportunity, Channel, Actor, ActionItem, Artifact, Meeting, Technology, Campaign, UseCase) dedupe via @key.
7. Risky/large writes: \`branches_create\` from main → \`ingest\` onto the branch → verify → \`branches_merge\` → \`branches_delete\`. \`schema_apply\` skips branches: it is main-only and rejects open feature branches.
8. \`schema_apply\` is destructive and has no undo. Use \`schema_get\` + a local diff first. Non-nullable property adds require add-optional → backfill → tighten in two applies.

Date format: ISO strings on \`change\` params; integer days-since-epoch in ingest JSONL \`Date\` fields. \`DateTime\` is ISO on both.

If you see \`sync_branch()\` in an error message, it is server-internal text, NOT a tool. Retry once; on persistent failure, fall back to \`ingest\` on a branch.

Depth: https://github.com/ModernRelay/omnigraph-cookbooks/tree/main/skills/omnigraph-best-practices`;

export interface CreateServerOptions {
baseUrl: string;
Expand All @@ -42,10 +71,15 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
const og = new Omnigraph({ baseUrl: opts.baseUrl, token: opts.token, fetch: opts.fetch });
const defaultBranch = opts.defaultBranch ?? 'main';

const server = new McpServer({
name: 'omnigraph-mcp',
version: '0.4.0',
});
const server = new McpServer(
{
name: 'omnigraph-mcp',
version: '0.4.1',
},
{
instructions: INSTRUCTIONS,
},
);

// ---------- Tools: read-only -------------------------------------------

Expand Down Expand Up @@ -315,5 +349,57 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
},
);

// Best-practices references, vendored from omnigraph-cookbooks at build
// time. Agents pull these on demand; resource bodies stay out of the
// initial session context until `resources/read` is called.
for (const entry of COOKBOOK) {
server.registerResource(
`best-practices/${entry.key}`,
entry.uri,
{
title: entry.title,
description: entry.description,
mimeType: 'text/markdown',
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'text/markdown',
text: entry.body,
},
],
}),
);
}

// Index resource: a single small markdown that lists every cookbook
// reference + its purpose. An agent that has not yet decided which
// reference it needs can read this one cheap entry to orient.
server.registerResource(
'best-practices/index',
'omnigraph://best-practices/index',
{
title: 'Best-practices index',
description:
'Lists every omnigraph://best-practices/* resource and what topic each covers. Read this first if you are not sure which deeper reference applies.',
mimeType: 'text/markdown',
},
async (uri) => {
const lines = [
'# Omnigraph best-practices index',
'',
'Vendored from https://github.com/ModernRelay/omnigraph-cookbooks/tree/main/skills/omnigraph-best-practices.',
'',
'| Resource | Read before |',
'|---|---|',
...COOKBOOK.map((e) => `| \`${e.uri}\` | ${e.description} |`),
];
return {
contents: [{ uri: uri.href, mimeType: 'text/markdown', text: lines.join('\n') }],
};
},
);

return server;
}
Loading
Loading