diff --git a/packages/mcp/src/bin.ts b/packages/mcp/src/bin.ts index 47b5c48..00a53cd 100644 --- a/packages/mcp/src/bin.ts +++ b/packages/mcp/src/bin.ts @@ -12,7 +12,7 @@ if (!baseUrl) { process.exit(1); } -const server = createOmnigraphMcpServer({ +const server = await createOmnigraphMcpServer({ baseUrl, token: process.env.OMNIGRAPH_TOKEN, defaultBranch: process.env.OMNIGRAPH_DEFAULT_BRANCH, diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index d517b3d..0cadb70 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -15,9 +15,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { Omnigraph, type FetchLike, + type SavedQuery, + type SavedQueryParam, SERVER_VERSION as SDK_SERVER_VERSION, } from '@modernrelay/omnigraph'; -import { z } from 'zod'; +import { z, type ZodTypeAny } 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. @@ -44,6 +46,8 @@ Workflow norms (violating these breaks things or silently corrupts data): Date format: ISO strings on \`change\` params; integer days-since-epoch in ingest JSONL \`Date\` fields. \`DateTime\` is ISO on both. +Saved queries: tools prefixed \`q_\` (e.g. \`q_find_person\`) are user-authored .gq queries the operator has persisted via \`PUT /queries/{name}\`. They dispatch through \`read\` with the saved source. Call them like any other tool, passing the declared params as named args. The list at session start is a snapshot — saved queries added or deleted after startup are not visible until the MCP reconnects. Browse \`omnigraph://queries\` for the full source if a tool description is not enough. + 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`; @@ -67,7 +71,67 @@ function plainText(text: string) { return [{ type: 'text' as const, text }]; } -export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { +// Prefix saved-query tools so they cannot shadow built-in tool names. The +// server side also rejects reserved names like `read`, but the prefix is +// the defence-in-depth and keeps the catalogue obviously partitioned for +// human readers. +const SAVED_QUERY_TOOL_PREFIX = 'q_'; + +// Map an Omnigraph scalar/composite type name (as it appears in `.gq` +// source — `String`, `I32`, `Vector(3072)`, etc.) onto a permissive zod +// schema. The MCP boundary is only doing argument-shape validation; the +// server still parses and typechecks the query against the live schema, +// so any tighter checking here would be duplicative and could reject +// future scalars we have not seen yet. +function paramTypeToZod(typeName: string): ZodTypeAny { + switch (typeName) { + case 'String': + return z.string(); + case 'Bool': + return z.boolean(); + case 'I32': + case 'I64': + case 'U32': + case 'U64': + return z.number().int(); + case 'F32': + case 'F64': + return z.number(); + case 'Date': + case 'DateTime': + return z.string(); + default: + // Vector(N), Blob, [String], future scalars — pass through opaque. + return z.unknown(); + } +} + +function savedQueryInputShape(params: SavedQueryParam[]): Record { + const shape: Record = {}; + for (const p of params) { + const base = paramTypeToZod(p.typeName); + shape[p.name] = p.nullable ? base.optional() : base; + } + // Every saved query is dispatched through `/read`, so the caller may + // also pin a branch or a snapshot at invocation time without having + // to bake it into the saved source. + shape.branch = z.string().optional(); + shape.snapshot = z.string().optional(); + return shape; +} + +function savedQueryDescription(q: SavedQuery): string { + const params = + q.params.length === 0 + ? 'no parameters' + : q.params + .map((p) => `${p.name}: ${p.typeName}${p.nullable ? '?' : ''}`) + .join(', '); + const head = q.description ?? `Saved query \`${q.name}\``; + return `${head} — params: ${params}. Dispatched through \`read\`; pass values in the matching argument names, optionally override \`branch\` or pin \`snapshot\`.`; +} + +export async function createOmnigraphMcpServer(opts: CreateServerOptions): Promise { const og = new Omnigraph({ baseUrl: opts.baseUrl, token: opts.token, fetch: opts.fetch }); const defaultBranch = opts.defaultBranch ?? 'main'; @@ -373,6 +437,71 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { ); } + // ---------- Saved queries ----------------------------------------------- + // Pull whatever the user has saved server-side via `PUT /queries/{name}` + // and register one MCP tool per saved query, plus a list resource. This + // is best-effort: if the server is older than 0.4.3 the endpoint will + // 404, and if the network is flaky the list call will throw. In either + // case we keep the rest of the server intact rather than failing + // startup — operators get a stderr line so the absence is loud. + let savedQueries: SavedQuery[] = []; + try { + const listed = await og.queries.list(); + if (Array.isArray(listed)) { + savedQueries = listed; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn( + `omnigraph-mcp: failed to list saved queries (${message}); skipping per-query tool registration`, + ); + } + for (const saved of savedQueries) { + const toolName = `${SAVED_QUERY_TOOL_PREFIX}${saved.name}`; + server.registerTool( + toolName, + { + title: saved.description ?? `Saved query: ${saved.name}`, + description: savedQueryDescription(saved), + inputSchema: savedQueryInputShape(saved.params), + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + async (input: Record) => { + const { branch, snapshot, ...params } = input; + const target = + typeof snapshot === 'string' + ? { snapshot } + : { branch: (typeof branch === 'string' ? branch : undefined) ?? defaultBranch }; + const r = await og.read({ + querySource: saved.source, + queryName: saved.name, + params, + ...target, + }); + return { content: jsonText(r) }; + }, + ); + } + server.registerResource( + 'queries', + 'omnigraph://queries', + { + title: 'Saved queries', + description: + 'JSON list of every saved query (name, description, source, params). Mirrors what is exposed as `q_` tools — read this if you want to pick a query by description rather than scrolling the tool catalogue.', + mimeType: 'application/json', + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: 'application/json', + text: JSON.stringify(savedQueries, null, 2), + }, + ], + }), + ); + // 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. diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index cede864..7e633d1 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -57,12 +57,15 @@ function fakeFetch(): typeof globalThis.fetch { ], }); } + if (method === 'GET' && path === '/queries') { + return respond(200, { queries: [] }); + } return respond(404, { error: 'not found', code: 'not_found' }); }) as unknown as typeof globalThis.fetch; } -async function setup() { - const server = createOmnigraphMcpServer({ baseUrl: 'http://x', fetch: fakeFetch() }); +async function setup(fetchImpl: typeof globalThis.fetch = fakeFetch()) { + const server = await createOmnigraphMcpServer({ baseUrl: 'http://x', fetch: fetchImpl }); const client = new Client({ name: 'test-client', version: '0.0.0' }); const [clientT, serverT] = InMemoryTransport.createLinkedPair(); await Promise.all([server.connect(serverT), client.connect(clientT)]); @@ -171,6 +174,7 @@ describe('omnigraph-mcp server', () => { 'omnigraph://best-practices/schema', 'omnigraph://best-practices/search', 'omnigraph://branches', + 'omnigraph://queries', 'omnigraph://schema', ].sort(), ); @@ -216,7 +220,7 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); }) as unknown as typeof globalThis.fetch; - const server = createOmnigraphMcpServer({ + const server = await createOmnigraphMcpServer({ baseUrl: 'http://x', defaultBranch: 'review-2026', fetch: recordingFetch, @@ -251,7 +255,7 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); }) as unknown as typeof globalThis.fetch; - const server = createOmnigraphMcpServer({ + const server = await createOmnigraphMcpServer({ baseUrl: 'http://x', defaultBranch: 'main', fetch: recordingFetch, @@ -274,4 +278,136 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as const r = await client.callTool({ name: 'read', arguments: {} }); expect(r.isError).toBe(true); }); + + it('registers one q_ tool per saved query and dispatches through /read', async () => { + let readBody: Record | undefined; + const fetchWithSavedQuery: typeof globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const path = new URL(url).pathname; + const method = init?.method ?? 'GET'; + if (method === 'GET' && path === '/queries') { + return new Response( + JSON.stringify({ + queries: [ + { + name: 'find_person', + description: 'by name', + source: + 'query find_person($name: String) { match { $p: Person { name: $name } } return { $p.name } }', + params: [{ name: 'name', type_name: 'String', nullable: false }], + updated_at_us: '1747315200000000', + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + if (method === 'POST' && path === '/read') { + readBody = JSON.parse(typeof init?.body === 'string' ? init.body : '{}'); + return new Response( + JSON.stringify({ + query_name: 'find_person', + target: { branch: 'main', snapshot: null }, + row_count: 1, + columns: ['$p.name'], + rows: [{ '$p.name': 'Alice' }], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof globalThis.fetch; + + const { client } = await setup(fetchWithSavedQuery); + const tools = await client.listTools(); + const names = tools.tools.map((t) => t.name); + expect(names).toContain('q_find_person'); + + const result = await client.callTool({ + name: 'q_find_person', + arguments: { name: 'Alice' }, + }); + expect(result.isError).toBeFalsy(); + expect(readBody?.query_source).toContain('query find_person'); + expect(readBody?.params).toEqual({ name: 'Alice' }); + expect(readBody?.branch).toBe('main'); + }); + + it('q_ tool routes to a snapshot when one is passed', async () => { + let readBody: Record | undefined; + const fetchImpl: typeof globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const path = new URL(url).pathname; + const method = init?.method ?? 'GET'; + if (method === 'GET' && path === '/queries') { + return new Response( + JSON.stringify({ + queries: [ + { + name: 'all_people', + description: null, + source: 'query all_people() { match { $p: Person } return { $p.name } }', + params: [], + updated_at_us: '1747315200000000', + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + if (method === 'POST' && path === '/read') { + readBody = JSON.parse(typeof init?.body === 'string' ? init.body : '{}'); + return new Response( + JSON.stringify({ + query_name: 'all_people', + target: { branch: null, snapshot: 'snap-1' }, + row_count: 0, + columns: [], + rows: [], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof globalThis.fetch; + + const { client } = await setup(fetchImpl); + await client.callTool({ name: 'q_all_people', arguments: { snapshot: 'snap-1' } }); + expect(readBody?.snapshot).toBe('snap-1'); + expect(readBody?.branch).toBeUndefined(); + }); + + it('continues to start when /queries returns 404 (older server)', async () => { + const fetchOldServer: typeof globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const path = new URL(url).pathname; + const method = init?.method ?? 'GET'; + if (method === 'GET' && path === '/queries') { + return new Response(JSON.stringify({ error: 'not found', code: 'not_found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof globalThis.fetch; + + // Should not throw; built-in tools still registered. + const { client } = await setup(fetchOldServer); + const tools = await client.listTools(); + const names = tools.tools.map((t) => t.name); + expect(names).toContain('read'); + expect(names.find((n) => n.startsWith('q_'))).toBeUndefined(); + }); }); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 6214207..dd29a1b 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -4,6 +4,7 @@ import type { FetchLike } from './transport'; import { BranchesResource } from './resources/branches'; import type { CallOptions } from './internals'; import { CommitsResource } from './resources/commits'; +import { QueriesResource } from './resources/queries'; import { SchemaResource } from './resources/schema'; import type { Change, @@ -44,6 +45,7 @@ export interface SnapshotInput { export default class Omnigraph { readonly branches: BranchesResource; readonly commits: CommitsResource; + readonly queries: QueriesResource; readonly schema: SchemaResource; private readonly t: Transport; @@ -52,6 +54,7 @@ export default class Omnigraph { this.t = new Transport(opts); this.branches = new BranchesResource(this.t); this.commits = new CommitsResource(this.t); + this.queries = new QueriesResource(this.t); this.schema = new SchemaResource(this.t); } diff --git a/packages/sdk/src/generated/index.ts b/packages/sdk/src/generated/index.ts index 8401dde..6e72e44 100644 --- a/packages/sdk/src/generated/index.ts +++ b/packages/sdk/src/generated/index.ts @@ -33,6 +33,11 @@ export { type DeleteBranchErrors, type DeleteBranchResponse, type DeleteBranchResponses, + type DeleteQueryData, + type DeleteQueryError, + type DeleteQueryErrors, + type DeleteQueryResponse, + type DeleteQueryResponses, ErrorCode, type ErrorOutput, type ExportData, @@ -45,6 +50,11 @@ export { type GetCommitErrors, type GetCommitResponse, type GetCommitResponses, + type GetQueryData, + type GetQueryError, + type GetQueryErrors, + type GetQueryResponse, + type GetQueryResponses, type GetSchemaData, type GetSchemaError, type GetSchemaErrors, @@ -77,6 +87,11 @@ export { type ListCommitsErrors, type ListCommitsResponse, type ListCommitsResponses, + type ListQueriesData, + type ListQueriesError, + type ListQueriesErrors, + type ListQueriesResponse, + type ListQueriesResponses, LoadMode, type ManifestConflictOutput, type MergeBranchesData, @@ -94,6 +109,16 @@ export { type ReadResponse, type ReadResponses, type ReadTargetOutput, + type SavedQueryDeleteOutput, + type SavedQueryListOutput, + type SavedQueryOutput, + type SavedQueryParamOutput, + type SaveQueryData, + type SaveQueryError, + type SaveQueryErrors, + type SaveQueryRequest, + type SaveQueryResponse, + type SaveQueryResponses, type SchemaApplyOutput, type SchemaApplyRequest, type SchemaOutput, diff --git a/packages/sdk/src/generated/types.gen.ts b/packages/sdk/src/generated/types.gen.ts index f80c532..62b1c51 100644 --- a/packages/sdk/src/generated/types.gen.ts +++ b/packages/sdk/src/generated/types.gen.ts @@ -260,6 +260,52 @@ export type ReadTargetOutput = { snapshot?: string | null; }; +export type SaveQueryRequest = { + /** + * Optional human-readable description. Not used during execution; surfaced + * to clients so a saved query can carry its own help text. + */ + description?: string | null; + /** + * Full `.gq` source. Must declare exactly one `query (...)` block + * whose declared name matches the URL `{name}`. The server parses the + * source at save time and persists the extracted parameter signature + * alongside it; clients (MCP, future UIs) use that signature to build + * typed inputs. + */ + source: string; +}; + +export type SavedQueryDeleteOutput = { + /** + * `true` if the saved query existed and was removed, `false` if it did + * not exist (delete is idempotent). + */ + deleted: boolean; + name: string; +}; + +export type SavedQueryListOutput = { + queries: Array; +}; + +export type SavedQueryOutput = { + description?: string | null; + name: string; + params: Array; + source: string; + /** + * Last write time as Unix epoch microseconds, encoded as a decimal string. + */ + updated_at_us: string; +}; + +export type SavedQueryParamOutput = { + name: string; + nullable: boolean; + type_name: string; +}; + export type SchemaApplyOutput = { applied: boolean; manifest_version: number; @@ -647,6 +693,147 @@ export type IngestResponses = { export type IngestResponse = IngestResponses[keyof IngestResponses]; +export type ListQueriesData = { + body?: never; + path?: never; + query?: never; + url: "/queries"; +}; + +export type ListQueriesErrors = { + /** + * Unauthorized + */ + 401: ErrorOutput; + /** + * Forbidden + */ + 403: ErrorOutput; +}; + +export type ListQueriesError = ListQueriesErrors[keyof ListQueriesErrors]; + +export type ListQueriesResponses = { + /** + * All saved queries, ordered by name + */ + 200: SavedQueryListOutput; +}; + +export type ListQueriesResponse = + ListQueriesResponses[keyof ListQueriesResponses]; + +export type DeleteQueryData = { + body?: never; + path: { + /** + * Saved query name + */ + name: string; + }; + query?: never; + url: "/queries/{name}"; +}; + +export type DeleteQueryErrors = { + /** + * Unauthorized + */ + 401: ErrorOutput; + /** + * Forbidden + */ + 403: ErrorOutput; +}; + +export type DeleteQueryError = DeleteQueryErrors[keyof DeleteQueryErrors]; + +export type DeleteQueryResponses = { + /** + * Delete result. `deleted` is false if the query did not exist (idempotent). + */ + 200: SavedQueryDeleteOutput; +}; + +export type DeleteQueryResponse = + DeleteQueryResponses[keyof DeleteQueryResponses]; + +export type GetQueryData = { + body?: never; + path: { + /** + * Saved query name + */ + name: string; + }; + query?: never; + url: "/queries/{name}"; +}; + +export type GetQueryErrors = { + /** + * Unauthorized + */ + 401: ErrorOutput; + /** + * Forbidden + */ + 403: ErrorOutput; + /** + * Not found + */ + 404: ErrorOutput; +}; + +export type GetQueryError = GetQueryErrors[keyof GetQueryErrors]; + +export type GetQueryResponses = { + /** + * The saved query + */ + 200: SavedQueryOutput; +}; + +export type GetQueryResponse = GetQueryResponses[keyof GetQueryResponses]; + +export type SaveQueryData = { + body: SaveQueryRequest; + path: { + /** + * Saved query name (must match the source's declared query name) + */ + name: string; + }; + query?: never; + url: "/queries/{name}"; +}; + +export type SaveQueryErrors = { + /** + * Bad request — invalid name, source did not parse, or declared name does not match + */ + 400: ErrorOutput; + /** + * Unauthorized + */ + 401: ErrorOutput; + /** + * Forbidden + */ + 403: ErrorOutput; +}; + +export type SaveQueryError = SaveQueryErrors[keyof SaveQueryErrors]; + +export type SaveQueryResponses = { + /** + * The saved query (insert or overwrite) + */ + 200: SavedQueryOutput; +}; + +export type SaveQueryResponse = SaveQueryResponses[keyof SaveQueryResponses]; + export type ReadData = { body: ReadRequest; path?: never; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 55cdd1d..765433e 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -36,6 +36,12 @@ export type { // Commits Commit, CommitList, + // Saved queries + SavedQuery, + SavedQueryParam, + SavedQueryList, + SavedQueryDelete, + SaveQueryInput, // Schema Schema, SchemaApply, diff --git a/packages/sdk/src/resources/queries.ts b/packages/sdk/src/resources/queries.ts new file mode 100644 index 0000000..2c236b0 --- /dev/null +++ b/packages/sdk/src/resources/queries.ts @@ -0,0 +1,61 @@ +import type { Transport } from '../transport'; +import type { + SavedQuery, + SavedQueryDelete, + SavedQueryList, + SaveQueryInput, +} from '../types'; +import type { CallOptions } from '../internals'; + +export class QueriesResource { + constructor(private readonly t: Transport) {} + + /** + * List every saved query. Each entry includes the full `.gq` source + * and the declared parameter signature, so callers do not need a + * follow-up `get` to render or invoke them. + */ + async list(opts: CallOptions = {}): Promise { + const r = await this.t.request('GET', '/queries', { + signal: opts.signal, + }); + return r.queries; + } + + /** + * Retrieve a saved query by name. Throws `NotFoundError` if absent. + */ + get(name: string, opts: CallOptions = {}): Promise { + return this.t.request( + 'GET', + `/queries/${encodeURIComponent(name)}`, + { signal: opts.signal }, + ); + } + + /** + * Insert or overwrite a saved query. The `.gq` source must declare + * exactly one `query (...)` block whose name matches `name` — + * the server uses that 1:1 mapping to keep saved queries unambiguous + * for downstream callers like the MCP server. + */ + save(name: string, input: SaveQueryInput, opts: CallOptions = {}): Promise { + return this.t.request( + 'PUT', + `/queries/${encodeURIComponent(name)}`, + { body: input, signal: opts.signal }, + ); + } + + /** + * Delete a saved query. Idempotent: deleting an absent query returns + * `{ deleted: false }`. + */ + delete(name: string, opts: CallOptions = {}): Promise { + return this.t.request( + 'DELETE', + `/queries/${encodeURIComponent(name)}`, + { signal: opts.signal }, + ); + } +} diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 5166986..21f9288 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -29,6 +29,11 @@ import type { ReadOutput, ReadRequest, ReadTargetOutput, + SaveQueryRequest, + SavedQueryDeleteOutput, + SavedQueryListOutput, + SavedQueryOutput, + SavedQueryParamOutput, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, @@ -59,6 +64,10 @@ export type Ingest = Camelize; export type IngestTable = Camelize; export type Read = Camelize; export type ReadTarget = Camelize; +export type SavedQuery = Camelize; +export type SavedQueryParam = Camelize; +export type SavedQueryList = Camelize; +export type SavedQueryDelete = Camelize; export type Schema = Camelize; export type SchemaApply = Camelize; export type Snapshot = Camelize; @@ -73,6 +82,7 @@ export type ChangeInput = Camelize; export type ExportInput = Camelize; export type IngestInput = Camelize; export type ReadInput = Camelize; +export type SaveQueryInput = Camelize; export type SchemaApplyInput = Camelize; // Enums and discriminators are unchanged (no snake-case keys to convert). diff --git a/packages/sdk/test/queries.test.ts b/packages/sdk/test/queries.test.ts new file mode 100644 index 0000000..5221830 --- /dev/null +++ b/packages/sdk/test/queries.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import Omnigraph, { NotFoundError } from '../src'; +import { stubFetch } from './helpers'; + +describe('queries resource', () => { + it('list returns the saved queries array, GET /queries', async () => { + const { fetch, calls } = stubFetch({ + body: { + queries: [ + { + name: 'find_person', + description: 'by name', + source: 'query find_person($name: String) { ... }', + params: [{ name: 'name', type_name: 'String', nullable: false }], + updated_at_us: '1747315200000000', + }, + ], + }, + }); + const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const result = await og.queries.list(); + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe('find_person'); + // The snake-case params field should arrive camelCased. + expect(result[0]?.params[0]?.typeName).toBe('String'); + expect(result[0]?.updatedAtUs).toBe('1747315200000000'); + expect(calls[0]?.method).toBe('GET'); + expect(calls[0]?.url).toBe('http://x/queries'); + }); + + it('get fetches by name and surfaces NotFoundError on 404', async () => { + const { fetch: fetchOk, calls: callsOk } = stubFetch({ + body: { + name: 'find_person', + description: null, + source: 'query find_person($name: String) { ... }', + params: [{ name: 'name', type_name: 'String', nullable: false }], + updated_at_us: '1747315200000000', + }, + }); + const og = new Omnigraph({ baseUrl: 'http://x', fetch: fetchOk }); + const r = await og.queries.get('find_person'); + expect(r.name).toBe('find_person'); + expect(callsOk[0]?.url).toBe('http://x/queries/find_person'); + + const { fetch: fetchMissing } = stubFetch({ + status: 404, + body: { error: 'saved query not found', code: 'not_found' }, + }); + const og2 = new Omnigraph({ baseUrl: 'http://x', fetch: fetchMissing }); + await expect(og2.queries.get('nope')).rejects.toBeInstanceOf(NotFoundError); + }); + + it('save sends PUT with camel→snake body conversion', async () => { + const { fetch, calls } = stubFetch({ + body: { + name: 'find_person', + description: 'by name', + source: 'query find_person($name: String) { ... }', + params: [{ name: 'name', type_name: 'String', nullable: false }], + updated_at_us: '1747315200000000', + }, + }); + const og = new Omnigraph({ baseUrl: 'http://x', token: 't', fetch }); + await og.queries.save('find_person', { + source: 'query find_person($name: String) { ... }', + description: 'by name', + }); + expect(calls[0]?.method).toBe('PUT'); + expect(calls[0]?.url).toBe('http://x/queries/find_person'); + expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ + source: 'query find_person($name: String) { ... }', + description: 'by name', + }); + expect(calls[0]?.headers['authorization']).toBe('Bearer t'); + }); + + it('delete escapes the name and returns the deleted flag', async () => { + const { fetch, calls } = stubFetch({ body: { name: 'a b', deleted: true } }); + const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const r = await og.queries.delete('a b'); + expect(calls[0]?.method).toBe('DELETE'); + expect(calls[0]?.url).toBe('http://x/queries/a%20b'); + expect(r.deleted).toBe(true); + }); +}); diff --git a/spec/openapi.json b/spec/openapi.json index ea62e31..474eaee 100644 --- a/spec/openapi.json +++ b/spec/openapi.json @@ -684,6 +684,251 @@ ] } }, + "/queries": { + "get": { + "tags": [ + "queries" + ], + "summary": "List every saved query.", + "description": "Each entry includes the full `.gq` source plus the declared parameter\nsignature, so a client can render or invoke them without a follow-up\nfetch.", + "operationId": "listQueries", + "responses": { + "200": { + "description": "All saved queries, ordered by name", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedQueryListOutput" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + } + }, + "/queries/{name}": { + "get": { + "tags": [ + "queries" + ], + "summary": "Retrieve a saved query by name.", + "operationId": "getQuery", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Saved query name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The saved query", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedQueryOutput" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + }, + "put": { + "tags": [ + "queries" + ], + "summary": "Save (insert or overwrite) a named query.", + "description": "`source` must declare exactly one `query (...)` block whose name\nmatches the URL `{name}`. The server parses the source at save time and\npersists the declared parameter signature alongside it. The 1:1 mapping\nbetween URL name and `.gq` query name keeps the saved-query →\nMCP-tool mapping unambiguous.", + "operationId": "saveQuery", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Saved query name (must match the source's declared query name)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveQueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The saved query (insert or overwrite)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedQueryOutput" + } + } + } + }, + "400": { + "description": "Bad request — invalid name, source did not parse, or declared name does not match", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + }, + "delete": { + "tags": [ + "queries" + ], + "summary": "Delete a saved query. Idempotent — returns `deleted: false` if the\nquery did not exist.", + "operationId": "deleteQuery", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Saved query name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Delete result. `deleted` is false if the query did not exist (idempotent).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedQueryDeleteOutput" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + } + }, "/read": { "post": { "tags": [ @@ -1535,6 +1780,108 @@ } } }, + "SaveQueryRequest": { + "type": "object", + "required": [ + "source" + ], + "properties": { + "description": { + "type": [ + "string", + "null" + ], + "description": "Optional human-readable description. Not used during execution; surfaced\nto clients so a saved query can carry its own help text." + }, + "source": { + "type": "string", + "description": "Full `.gq` source. Must declare exactly one `query (...)` block\nwhose declared name matches the URL `{name}`. The server parses the\nsource at save time and persists the extracted parameter signature\nalongside it; clients (MCP, future UIs) use that signature to build\ntyped inputs.", + "example": "query find_person($name: String) {\n match {\n $p: Person { name: $name }\n }\n return { $p.name, $p.age }\n}" + } + } + }, + "SavedQueryDeleteOutput": { + "type": "object", + "required": [ + "name", + "deleted" + ], + "properties": { + "deleted": { + "type": "boolean", + "description": "`true` if the saved query existed and was removed, `false` if it did\nnot exist (delete is idempotent)." + }, + "name": { + "type": "string" + } + } + }, + "SavedQueryListOutput": { + "type": "object", + "required": [ + "queries" + ], + "properties": { + "queries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SavedQueryOutput" + } + } + } + }, + "SavedQueryOutput": { + "type": "object", + "required": [ + "name", + "source", + "params", + "updated_at_us" + ], + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SavedQueryParamOutput" + } + }, + "source": { + "type": "string" + }, + "updated_at_us": { + "type": "string", + "description": "Last write time as Unix epoch microseconds, encoded as a decimal string." + } + } + }, + "SavedQueryParamOutput": { + "type": "object", + "required": [ + "name", + "type_name", + "nullable" + ], + "properties": { + "name": { + "type": "string" + }, + "nullable": { + "type": "boolean" + }, + "type_name": { + "type": "string" + } + } + }, "SchemaApplyOutput": { "type": "object", "required": [