From 0da4de1ad1e900abe15c72c2e71ff429a2849c55 Mon Sep 17 00:00:00 2001 From: Joep Meindertsma Date: Mon, 28 Sep 2026 15:27:37 +0000 Subject: [PATCH 1/7] Add an MCP server so LLM clients can read and edit Atomic Data `@tomic/mcp` (browser/mcp) is a stdio MCP server for Claude Code, Claude Desktop, Cursor and other clients. It runs locally and signs every edit with the user's own Agent key, so writes are ordinary signed commits. Tools: list_drives, get_resource (documents and meetings include their text), search, semantic_search, query, get_user_classes, get_schema, create_resource, edit_resource and delete_resource (never a whole drive). The data verbs behind those tools now live in @tomic/lib (assistant-tools.ts), and the in-app assistant calls the same functions, so there is one implementation behind both surfaces. The compact JSON-AD dialect, short subject refs and class helpers move from the data-browser to @tomic/lib for the same reason. Reads no longer include the genesis certificate, which is long and tells a model nothing. This is steps 1 and 2 of planning/mcp-endpoint.md; a hosted endpoint with OAuth (for claude.ai connectors) is not part of this change. --- .../AI/AIChatMessageParts/MessageToolPart.tsx | 2 +- .../src/chunks/AI/ClientOnlyTransport.ts | 4 +- .../src/chunks/AI/atomicSchemaHelpers.ts | 91 ----- .../chunks/AI/processAtomicResources.test.ts | 3 +- .../src/chunks/AI/processAtomicResources.ts | 4 +- .../src/chunks/AI/resourceContextProviders.ts | 4 +- .../src/chunks/AI/tableContextProvider.ts | 4 +- .../src/chunks/AI/updateTableRows.test.ts | 3 +- .../src/chunks/AI/updateTableRows.ts | 4 +- .../src/chunks/AI/useAtomicTools.ts | 290 ++------------ .../src/chunks/AI/useGetDriveStructure.ts | 2 +- .../src/chunks/Website/websiteTools.ts | 2 +- .../src/components/datatypes/Markdown.tsx | 2 +- browser/lib/src/assistant-tools.ts | 303 ++++++++++++++ browser/lib/src/class-schema.ts | 126 ++++++ browser/lib/src/index.ts | 5 + .../src/json-ad-compact.test.ts} | 4 +- .../src/json-ad-compact.ts} | 22 +- .../src/standard-class-alias.test.ts} | 6 +- .../src/standard-class-alias.ts} | 6 +- .../src/subject-refs.test.ts} | 7 +- .../src/subject-refs.ts} | 3 +- browser/mcp/.gitignore | 1 + browser/mcp/README.md | 77 ++++ browser/mcp/package.json | 46 +++ browser/mcp/src/document-text.test.ts | 62 +++ browser/mcp/src/document-text.ts | 101 +++++ browser/mcp/src/index.ts | 53 +++ browser/mcp/src/server.ts | 377 ++++++++++++++++++ browser/mcp/tsconfig.json | 15 + browser/pnpm-lock.yaml | 46 ++- planning/mcp-endpoint.md | 16 +- 32 files changed, 1285 insertions(+), 406 deletions(-) delete mode 100644 browser/data-browser/src/chunks/AI/atomicSchemaHelpers.ts create mode 100644 browser/lib/src/assistant-tools.ts create mode 100644 browser/lib/src/class-schema.ts rename browser/{data-browser/src/chunks/AI/jsonAdCompact.test.ts => lib/src/json-ad-compact.test.ts} (98%) rename browser/{data-browser/src/chunks/AI/jsonAdCompact.ts => lib/src/json-ad-compact.ts} (96%) rename browser/{data-browser/src/chunks/AI/standardClassAlias.test.ts => lib/src/standard-class-alias.test.ts} (77%) rename browser/{data-browser/src/chunks/AI/standardClassAlias.ts => lib/src/standard-class-alias.ts} (76%) rename browser/{data-browser/src/helpers/subjectRefs.test.ts => lib/src/subject-refs.test.ts} (95%) rename browser/{data-browser/src/helpers/subjectRefs.ts => lib/src/subject-refs.ts} (98%) create mode 100644 browser/mcp/.gitignore create mode 100644 browser/mcp/README.md create mode 100644 browser/mcp/package.json create mode 100644 browser/mcp/src/document-text.test.ts create mode 100644 browser/mcp/src/document-text.ts create mode 100644 browser/mcp/src/index.ts create mode 100644 browser/mcp/src/server.ts create mode 100644 browser/mcp/tsconfig.json diff --git a/browser/data-browser/src/chunks/AI/AIChatMessageParts/MessageToolPart.tsx b/browser/data-browser/src/chunks/AI/AIChatMessageParts/MessageToolPart.tsx index b1eacd6137..0b05c9da0d 100644 --- a/browser/data-browser/src/chunks/AI/AIChatMessageParts/MessageToolPart.tsx +++ b/browser/data-browser/src/chunks/AI/AIChatMessageParts/MessageToolPart.tsx @@ -21,7 +21,7 @@ import { InlineFormattedResourceList } from '@components/InlineFormattedResource import { useResource } from '@tomic/react'; import { MCP_TOOL_NAMES } from '../defaultMCPServers'; import { core, Client } from '@tomic/lib'; -import { tryExpandRef } from '@helpers/subjectRefs'; +import { tryExpandRef } from '@tomic/react'; interface ToolMessageProps { part: ToolUIPart | DynamicToolUIPart; diff --git a/browser/data-browser/src/chunks/AI/ClientOnlyTransport.ts b/browser/data-browser/src/chunks/AI/ClientOnlyTransport.ts index 39d99d0d24..9d33d9145d 100644 --- a/browser/data-browser/src/chunks/AI/ClientOnlyTransport.ts +++ b/browser/data-browser/src/chunks/AI/ClientOnlyTransport.ts @@ -21,8 +21,8 @@ import { createOllama } from 'ollama-ai-provider-v2'; import { addFieldsIf } from '@helpers/addIf'; import { stringifyTree, useGetDriveStructure } from './useGetDriveStructure'; import { useSettings } from '@helpers/AppSettings'; -import { shortenSubject } from '@helpers/subjectRefs'; -import { getClassesOnDrive } from './atomicSchemaHelpers'; +import { shortenSubject } from '@tomic/react'; +import { getClassesOnDrive } from '@tomic/react'; import { createHostedModel } from '@helpers/managed/ai'; import { hostedVoiceModel } from './hostedVoiceModel'; diff --git a/browser/data-browser/src/chunks/AI/atomicSchemaHelpers.ts b/browser/data-browser/src/chunks/AI/atomicSchemaHelpers.ts deleted file mode 100644 index d7a7f861a8..0000000000 --- a/browser/data-browser/src/chunks/AI/atomicSchemaHelpers.ts +++ /dev/null @@ -1,91 +0,0 @@ -// @wc-ignore-file -import { core, type Core, type Store } from '@tomic/react'; - -export const toClassString = async (subject: string, store: Store) => { - const resource = await store.getResource(subject); - - if (resource.error || resource.loading) { - return `Could not read class: ${subject}`; - } - - const requiredLines = await Promise.all( - (resource.props.requires ?? []).map((prop: string) => - toPropertyLine(prop, store), - ), - ); - - const recommendedLines = await Promise.all( - (resource.props.recommends ?? []).map((prop: string) => - toPropertyLine(prop, store), - ), - ); - - return `Class ${resource.title} has the following properties: -Required: -${requiredLines.join('\n') || 'None'} - -Optional: -${recommendedLines.join('\n') || 'None'}`; -}; - -export const toPropertyLine = async (subject: string, store: Store) => { - const resource = await store.getResource(subject); - - if (resource.error || resource.loading) { - return `Could not read property: ${subject}`; - } - - return `- ${resource.title} (${subject})`; -}; - -export async function toClassObject(subject: string, store: Store) { - const resource = await store.getResource(subject); - - if (resource.error || resource.loading) { - return `Could not read class: ${subject}`; - } - - return { - subject, - shortname: resource.props.shortname, - description: resource.props.description, - required: await Promise.all( - (resource.props.requires ?? []).map(prop => - toPropertyObject(prop, store), - ), - ), - recommended: await Promise.all( - (resource.props.recommends ?? []).map(prop => - toPropertyObject(prop, store), - ), - ), - }; -} - -export async function getClassesOnDrive( - drive: string, - store: Store, -): Promise { - return store.search('', { - filters: { - [core.properties.isA]: core.classes.class, - }, - parents: [drive], - include: true, - limit: 1000, - }); -} - -async function toPropertyObject(subject: string, store: Store) { - const resource = await store.getResource(subject); - - if (resource.error || resource.loading) { - return `Could not read property: ${subject}`; - } - - return { - subject, - shortname: resource.props.shortname, - datatype: resource.props.datatype, - }; -} diff --git a/browser/data-browser/src/chunks/AI/processAtomicResources.test.ts b/browser/data-browser/src/chunks/AI/processAtomicResources.test.ts index 8aa18491a9..5a5801aefe 100644 --- a/browser/data-browser/src/chunks/AI/processAtomicResources.test.ts +++ b/browser/data-browser/src/chunks/AI/processAtomicResources.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { Store } from '@tomic/react'; import { processAtomicResources } from './processAtomicResources'; -vi.mock('./jsonAdCompact', () => ({ +vi.mock('@tomic/react', async importOriginal => ({ + ...(await importOriginal()), buildClassContext: vi.fn().mockResolvedValue({}), describeClassCompact: vi.fn(), toCompact: vi.fn().mockResolvedValue({ name: 'Bread', price: 3 }), diff --git a/browser/data-browser/src/chunks/AI/processAtomicResources.ts b/browser/data-browser/src/chunks/AI/processAtomicResources.ts index 9993a9d5cd..c5ce3efc8c 100644 --- a/browser/data-browser/src/chunks/AI/processAtomicResources.ts +++ b/browser/data-browser/src/chunks/AI/processAtomicResources.ts @@ -1,12 +1,12 @@ // @wc-ignore-file import type { Store } from '@tomic/react'; import type { AIMessageContext } from './types'; -import { shortenRefsDeep } from '@helpers/subjectRefs'; +import { shortenRefsDeep } from '@tomic/react'; import { buildClassContext, describeClassCompact, toCompact, -} from './jsonAdCompact'; +} from '@tomic/react'; import { getClassContextForAgent } from './resourceContextProviders'; /** diff --git a/browser/data-browser/src/chunks/AI/resourceContextProviders.ts b/browser/data-browser/src/chunks/AI/resourceContextProviders.ts index bdbf94b6ba..0f405a6ad1 100644 --- a/browser/data-browser/src/chunks/AI/resourceContextProviders.ts +++ b/browser/data-browser/src/chunks/AI/resourceContextProviders.ts @@ -20,10 +20,10 @@ import { type Resource, type Store, } from '@tomic/react'; -import { shortenSubject } from '@helpers/subjectRefs'; +import { shortenSubject } from '@tomic/react'; import { getDocumentContentForAgent } from './getDocumentContentForAgent'; import { hasDocumentContent } from '@chunks/RTE/readDocumentV2TiptapJson'; -import { buildClassContext, describeClassCompact } from './jsonAdCompact'; +import { buildClassContext, describeClassCompact } from '@tomic/react'; import { getTableContextForAgent } from './tableContextProvider'; const MESSAGE_SAMPLE_LIMIT = 10; diff --git a/browser/data-browser/src/chunks/AI/tableContextProvider.ts b/browser/data-browser/src/chunks/AI/tableContextProvider.ts index 5af5daff96..3fdde438c7 100644 --- a/browser/data-browser/src/chunks/AI/tableContextProvider.ts +++ b/browser/data-browser/src/chunks/AI/tableContextProvider.ts @@ -13,12 +13,12 @@ import { type Resource, type Store, } from '@tomic/react'; -import { shortenRefsDeep, shortenSubject } from '@helpers/subjectRefs'; +import { shortenRefsDeep, shortenSubject } from '@tomic/react'; import { buildClassContext, describeClassCompact, toCompact, -} from './jsonAdCompact'; +} from '@tomic/react'; const ROW_SAMPLE_LIMIT = 20; diff --git a/browser/data-browser/src/chunks/AI/updateTableRows.test.ts b/browser/data-browser/src/chunks/AI/updateTableRows.test.ts index 4f4019edaf..f2630a725f 100644 --- a/browser/data-browser/src/chunks/AI/updateTableRows.test.ts +++ b/browser/data-browser/src/chunks/AI/updateTableRows.test.ts @@ -1,7 +1,8 @@ import { expect, it, vi } from 'vitest'; import { core, type Store } from '@tomic/lib'; import { updateTableRows } from './updateTableRows'; -vi.mock('./jsonAdCompact', () => ({ +vi.mock('@tomic/react', async importOriginal => ({ + ...(await importOriginal()), buildClassContext: async () => ({}), resolveKey: (_: unknown, key: string) => ({ subject: key }), coerceValueIn: (_: unknown, value: unknown) => value, diff --git a/browser/data-browser/src/chunks/AI/updateTableRows.ts b/browser/data-browser/src/chunks/AI/updateTableRows.ts index 56c21bd55f..1565bc94d5 100644 --- a/browser/data-browser/src/chunks/AI/updateTableRows.ts +++ b/browser/data-browser/src/chunks/AI/updateTableRows.ts @@ -6,8 +6,8 @@ import { type Resource, type JSONValue, } from '@tomic/lib'; -import { expandSubject, shortenRefsDeep } from '@helpers/subjectRefs'; -import { buildClassContext, resolveKey, coerceValueIn } from './jsonAdCompact'; +import { expandSubject, shortenRefsDeep } from '@tomic/react'; +import { buildClassContext, resolveKey, coerceValueIn } from '@tomic/react'; export async function updateTableRows( store: Store, diff --git a/browser/data-browser/src/chunks/AI/useAtomicTools.ts b/browser/data-browser/src/chunks/AI/useAtomicTools.ts index c813faa7d3..27fa1903ee 100644 --- a/browser/data-browser/src/chunks/AI/useAtomicTools.ts +++ b/browser/data-browser/src/chunks/AI/useAtomicTools.ts @@ -1,4 +1,3 @@ -import { standardClassAlias } from './standardClassAlias'; import { updateTableRows } from './updateTableRows'; // @wc-ignore-file import { websiteTools } from '@chunks/Website/websiteTools'; @@ -6,15 +5,20 @@ import { useAppSetup } from '../../components/AppSetup/AppSetupProvider'; import { listAppSetups } from '../../components/AppSetup/registry'; import { previewEventSchema, previewTrigger } from './previewTrigger'; import { - Client, - commits, core, - dataBrowser, - server, + createResourceFromCompact, + expandSubject, + listDriveClasses, + queryResources, + readResourceCompact, + semanticSearch, + setResourceProperty, + shortenRefsDeep, + shortenSubject, + toClassObject, useStore, type JSONValue, type Resource, - type Store, } from '@tomic/react'; import { findSchema, pluginSchema } from '@tomic/lib'; import { discoverIntegrations } from './discoverIntegrations'; @@ -45,23 +49,8 @@ import { resolveView, } from '@chunks/TablePage/tableOps'; import { TABLE_TEMPLATES } from '@chunks/TablePage/tableTemplates'; -import { - expandSubject, - shortenRefsDeep, - shortenSubject, -} from '@helpers/subjectRefs'; -import { getClassesOnDrive, toClassObject } from './atomicSchemaHelpers'; import { useDocumentEditAgent } from './documentEditAgent'; import { getClassContextForAgent } from './resourceContextProviders'; -import { - buildClassContext, - coerceValueIn, - compactValueOut, - describeClassCompact, - fromCompact, - resolveKey, - toCompact, -} from './jsonAdCompact'; import type { AIModelIdentifier } from './types'; import { buildDashboardFromSpec, @@ -440,21 +429,6 @@ const viewConfigShape = { ), }; -const getClassesString = async ( - resource: Resource, - store: Store, -): Promise => { - const classes = []; - - for await (const cls of resource - .getClasses() - .map(async x => store.getResource(x))) { - classes.push(cls.title); - } - - return classes.join(', '); -}; - interface UseAtomicMCPToolsProps { onResourceEdited?: (originalResource: Resource) => void; editModel: AIModelIdentifier; @@ -518,51 +492,6 @@ export function useAtomicMCPTools({ }; const { verifyApp } = useAppVerifier(); - /** Resolves a `@class` shortname (or title) to a class subject on the - * current drive. Full URLs and `#refs` pass through/expand. */ - const resolveClass = async (nameOrRef: string): Promise => { - const nameOrSubject = expandSubject(nameOrRef); - - if (Client.isValidSubject(nameOrSubject)) { - return nameOrSubject; - } - - const standard = standardClassAlias(nameOrSubject); - if (standard) return standard; - - const classSubjects = await getClassesOnDrive(drive, store); - const wanted = nameOrSubject.toLowerCase(); - const matches: string[] = []; - - for (const subject of classSubjects) { - const resource = await store.getResource(subject); - const shortname = resource.get(core.properties.shortname) as - | string - | undefined; - - if ( - shortname?.toLowerCase() === wanted || - resource.title.toLowerCase() === wanted - ) { - matches.push(subject); - } - } - - if (matches.length === 1) { - return matches[0]; - } - - if (matches.length > 1) { - throw new Error( - `Ambiguous class "${nameOrSubject}": ${matches.join(', ')}. Use the full class URL.`, - ); - } - - throw new Error( - `Unknown class "${nameOrSubject}". Use get_user_classes to list available classes, or pass a full class URL.`, - ); - }; - /** Resolves a table reference and checks it really is a table. */ const resolveTable = async (reference: string) => { const table = await store.getResource(expandSubject(reference)); @@ -616,33 +545,14 @@ export function useAtomicMCPTools({ ) .optional(), }), - execute: async ({ query, limit, parents, text_query }) => { - if (limit < 1 || limit > 50) { - throw new Error('Limit must be between 1 and 50'); - } - - const results = await store.semanticSearch(query, { - limit, - parents: - parents && parents.length !== 0 - ? parents.map(expandSubject) - : [drive], - text_query, - }); - - return await Promise.all( - results.map(async res => { - const r = await store.getResource(res.subject); - - return { - subject: shortenSubject(res.subject), - title: r.title, - classes: await getClassesString(r, store), - chunk: res.chunk, - }; + execute: async ({ query, limit, parents, text_query }) => + shortenRefsDeep( + await semanticSearch(store, query, { + limit, + parents: parents && parents.length !== 0 ? parents : [drive], + textQuery: text_query, }), - ); - }, + ), strict: true, }), [TOOL_NAMES.QUERY]: tool({ @@ -676,82 +586,14 @@ export function useAtomicMCPTools({ .describe('The max number of results to return. Default is 30.') .default(30), }), - execute: async ({ - select = [ - core.properties.name, - core.properties.shortname, - server.properties.filename, - ], - where, - limit, - class: classRef, - }) => { + execute: async ({ select, where, limit, class: classRef }) => { try { - const classSubject = classRef - ? await resolveClass(classRef) - : undefined; - const ctx = classSubject - ? await buildClassContext(store, [classSubject]) - : undefined; - - const whereObj: Record = {}; - const filterProps: string[] = []; - - for (const { property, value } of where) { - if (!ctx && !Client.isValidSubject(property)) { - return `Error: Invalid property subject in where clause: '${property}'. Pass \`class\` to use shortnames.`; - } - - const info = ctx - ? resolveKey(ctx, property) - : { subject: property, shortname: property, datatype: '' }; - const coerced = coerceValueIn(info, value as JSONValue); - // The query index matches array membership on scalars. - whereObj[info.subject] = ( - Array.isArray(coerced) && coerced.length === 1 - ? coerced[0] - : coerced - ) as string | number | string[]; - filterProps.push(info.subject); - } - - if (classSubject) { - whereObj[core.properties.isA] = classSubject; - } - - const results = await store.search('', { - filters: whereObj, - limit, - include: true, - }); - - const resources = await Promise.all( - results.map(subject => store.getResource(subject)), - ); - - const selectProps = ctx - ? select.map(s => resolveKey(ctx, s).subject) - : select; - const props = Array.from(new Set([...selectProps, ...filterProps])); - return shortenRefsDeep( - resources.map(res => { - const obj: Record = { - '@id': res.subject, - }; - - for (const prop of props) { - const val = res.get(prop); - - if (val) { - const info = ctx?.bySubject.get(prop); - obj[info?.shortname ?? prop] = info - ? compactValueOut(info, val as JSONValue) - : val; - } - } - - return obj; + await queryResources(store, drive, { + class: classRef, + where: where as { property: string; value: JSONValue }[], + select, + limit, }), ); } catch (error) { @@ -784,22 +626,18 @@ export function useAtomicMCPTools({ for (const subjectOrRef of subjects) { const subject = expandSubject(subjectOrRef); - const res = await store.getResource(subject); - - if (res.error) { - result[subject] = `Error: ${res.error.message}`; + let entry: Record; + + try { + entry = await readResourceCompact(store, subject, { + includeCommitData, + }); + } catch (error) { + result[subject] = `Error: ${(error as Error).message}`; continue; } - const classes = res.getClasses(); - const ctx = await buildClassContext(store, classes); - const compact = await toCompact(store, res, { - includeCommitData, - context: ctx, - }); - - const entry: Record = compact; - entry._schema = classes.map(c => describeClassCompact(ctx, c)); + const res = await store.getResource(subject); // Class-specific view context: documents get _documentContent, // tables/chatrooms/folders/ontologies get a _view block — the @@ -847,20 +685,8 @@ export function useAtomicMCPTools({ description: 'List all classes defined on the current drive. Returns each class as `: `. Use this to discover available classes, then call `get_schema` for details on a specific class.', inputSchema: z.object({}), - execute: async () => { - const classSubjects = await getClassesOnDrive(drive, store); - - return await Promise.all( - classSubjects.map(async cls => { - const resource = await store.getResource(cls); - - return { - shortname: resource.title, - subject: shortenSubject(cls), - }; - }), - ); - }, + execute: async () => + shortenRefsDeep(await listDriveClasses(store, drive)), strict: true, }), [TOOL_NAMES.NAVIGATE_TO_RESOURCE]: tool({ @@ -1019,19 +845,19 @@ export function useAtomicMCPTools({ const originalResource = resource.clone(); try { - const ctx = await buildClassContext(store, resource.getClasses()); - const info = resolveKey(ctx, property); - const coerced = coerceValueIn(info, value as JSONValue); - - await resource.set(info.subject, coerced); + const { property: resolvedProperty, value: coerced } = + await setResourceProperty(store, subject, property, value, { + // The person reviews assistant edits before they are saved. + save: false, + }); // Notify parent component about the edited resource onResourceEdited?.(originalResource); const propertyEcho = - info.subject === property + resolvedProperty === property ? property - : `${property} (${info.subject})`; + : `${property} (${resolvedProperty})`; return `Changed property ${propertyEcho} on resource ${subject} to ${JSON.stringify(coerced)}`; } catch (error) { @@ -1122,38 +948,8 @@ NEVER omit spans of pre-existing text without using the \`\` ele ), }), execute: async ({ jsonAD }) => { - const createOne = async (data: Record) => { - const { isA, parent, propVals, resolved } = await fromCompact( - store, - data, - { resolveClass }, - ); - - const parentResource = await store.getResource(parent); - - if (parentResource.hasClasses(dataBrowser.classes.table)) { - // The parent is a table meaning the resource that is being created is a row. We should add a createdAt property to it. - propVals[commits.properties.createdAt] ??= Date.now(); - } - - const resource = await store.newResource({ - parent, - isA, - propVals, - }); - - await resource.save(); - - if ( - !parentResource.hasClasses(core.classes.ontology) && - !parentResource.hasClasses(dataBrowser.classes.table) - ) { - // Notify the store that we created a resource but not if the parent is an ontology or table as in that case we don't want them to show in the sidebar. - await store.notifyResourceManuallyCreated(resource); - } - - return { subject: resource.subject, resolved }; - }; + const createOne = (data: Record) => + createResourceFromCompact(store, drive, data); let data: unknown; diff --git a/browser/data-browser/src/chunks/AI/useGetDriveStructure.ts b/browser/data-browser/src/chunks/AI/useGetDriveStructure.ts index e5da6484c8..dd5244efc0 100644 --- a/browser/data-browser/src/chunks/AI/useGetDriveStructure.ts +++ b/browser/data-browser/src/chunks/AI/useGetDriveStructure.ts @@ -1,5 +1,5 @@ import { useSettings } from '@helpers/AppSettings'; -import { shortenSubject } from '@helpers/subjectRefs'; +import { shortenSubject } from '@tomic/react'; import { ai, CollectionBuilder, diff --git a/browser/data-browser/src/chunks/Website/websiteTools.ts b/browser/data-browser/src/chunks/Website/websiteTools.ts index fa226c8273..9f784e8982 100644 --- a/browser/data-browser/src/chunks/Website/websiteTools.ts +++ b/browser/data-browser/src/chunks/Website/websiteTools.ts @@ -9,7 +9,7 @@ import { websiteConfigSchema, } from './websiteModel'; import { buildWebsiteArtifact } from './websiteExport'; -import { expandSubject, shortenRefsDeep } from '@helpers/subjectRefs'; +import { expandSubject, shortenRefsDeep } from '@tomic/react'; export function websiteTools(store: Store, drive: string) { const expandConfig = (raw: z.infer) => ({ diff --git a/browser/data-browser/src/components/datatypes/Markdown.tsx b/browser/data-browser/src/components/datatypes/Markdown.tsx index 099004fc2c..267c371ece 100644 --- a/browser/data-browser/src/components/datatypes/Markdown.tsx +++ b/browser/data-browser/src/components/datatypes/Markdown.tsx @@ -7,7 +7,7 @@ import rehypeKatex from 'rehype-katex'; import 'katex/dist/katex.min.css'; import { Button } from '@components/Button'; import { truncateMarkdown } from '@helpers/markdown'; -import { tryExpandRef } from '@helpers/subjectRefs'; +import { tryExpandRef } from '@tomic/react'; import { FC, useState } from 'react'; import { AtomicLink, AtomicLinkProps } from '@components/AtomicLink'; import { isAtomicIdentifier } from '@tomic/react'; diff --git a/browser/lib/src/assistant-tools.ts b/browser/lib/src/assistant-tools.ts new file mode 100644 index 0000000000..9927f928d0 --- /dev/null +++ b/browser/lib/src/assistant-tools.ts @@ -0,0 +1,303 @@ +/** + * The data verbs an LLM uses to read and edit Atomic Data, without any UI. + * + * The in-app assistant (`useAtomicTools`) and the MCP server (`@tomic/mcp`) + * both call these, so there is one implementation behind every tool surface + * (see planning/mcp-endpoint.md). Everything speaks JSON-AD-Compact + * (`json-ad-compact.ts`). Results carry FULL subjects: shortening to `#refs` + * is the caller's choice, done at its own tool boundary. Failures throw; each + * surface decides how to report them. + */ +import { Client } from './client.js'; +import { + getClassesOnDrive, + getClassNames, + resolveClass, +} from './class-schema.js'; +import { + buildClassContext, + coerceValueIn, + compactValueOut, + describeClassCompact, + fromCompact, + resolveKey, + toCompact, +} from './json-ad-compact.js'; +import { commits } from './ontologies/commits.js'; +import { core } from './ontologies/core.js'; +import { dataBrowser } from './ontologies/dataBrowser.js'; +import { server } from './ontologies/server.js'; +import type { Store } from './store.js'; +import { expandSubject } from './subject-refs.js'; +import { GENESIS } from './urls.js'; +import type { JSONValue } from './value.js'; + +/** + * Reads one resource as compact JSON-AD, with a one-line `_schema` signature + * per class so the model rarely needs `get_schema` before writing. + */ +export async function readResourceCompact( + store: Store, + subjectOrRef: string, + { includeCommitData = false }: { includeCommitData?: boolean } = {}, +): Promise> { + const subject = expandSubject(subjectOrRef); + const resource = await store.getResource(subject); + + if (resource.error) { + throw new Error(resource.error.message); + } + + const classes = resource.getClasses(); + const ctx = await buildClassContext(store, classes); + const entry: Record = await toCompact(store, resource, { + includeCommitData, + context: ctx, + }); + // The genesis certificate is hundreds of base64 characters that only + // prove where the subject came from; no model needs to read it. + delete entry[GENESIS]; + entry._schema = classes.map(c => describeClassCompact(ctx, c)); + + return entry; +} + +export interface QueryResourcesOptions { + /** Class to query instances of: a shortname or full URL. Scopes shortname + * resolution for `where` and `select`, and adds an isA filter. */ + class?: string; + /** Filters. Shortnames and tag names when `class` is set, full property + * URLs otherwise. */ + where: { property: string; value: JSONValue }[]; + /** Properties to include. Defaults to name, shortname and filename. */ + select?: string[]; + limit?: number; +} + +/** Finds resources by property values. Results are not sorted. */ +export async function queryResources( + store: Store, + drive: string, + { + class: classRef, + where, + select = [ + core.properties.name, + core.properties.shortname, + server.properties.filename, + ], + limit = 30, + }: QueryResourcesOptions, +): Promise[]> { + const classSubject = classRef + ? await resolveClass(store, drive, classRef) + : undefined; + const ctx = classSubject + ? await buildClassContext(store, [classSubject]) + : undefined; + + const filters: Record = {}; + const filterProps: string[] = []; + + for (const { property, value } of where) { + if (!ctx && !Client.isValidSubject(property)) { + throw new Error( + `Invalid property subject in where clause: '${property}'. Pass \`class\` to use shortnames.`, + ); + } + + const info = ctx + ? resolveKey(ctx, property) + : { subject: property, shortname: property, datatype: '' }; + const coerced = coerceValueIn(info, value); + // The query index matches array membership on scalars. + filters[info.subject] = ( + Array.isArray(coerced) && coerced.length === 1 ? coerced[0] : coerced + ) as string | number | string[]; + filterProps.push(info.subject); + } + + if (classSubject) { + filters[core.properties.isA] = classSubject; + } + + const results = await store.search('', { + filters, + limit, + include: true, + }); + + const resources = await Promise.all( + results.map(subject => store.getResource(subject)), + ); + + const selectProps = ctx + ? select.map(s => resolveKey(ctx, s).subject) + : select; + const props = Array.from(new Set([...selectProps, ...filterProps])); + + return resources.map(res => { + const obj: Record = { '@id': res.subject }; + + for (const prop of props) { + const val = res.get(prop); + + if (val) { + const info = ctx?.bySubject.get(prop); + obj[info?.shortname ?? prop] = info + ? compactValueOut(info, val as JSONValue) + : val; + } + } + + return obj; + }); +} + +export interface FoundResource { + subject: string; + title: string; + classes: string; + /** For semantic search: the first chunk of the resource that matched. */ + chunk?: string; +} + +/** Full-text search, scoped to `parents` (usually a drive). */ +export async function textSearch( + store: Store, + query: string, + { parents, limit = 10 }: { parents?: string[]; limit?: number } = {}, +): Promise { + const subjects = await store.search(query, { + parents: parents?.map(expandSubject), + limit, + }); + + return Promise.all( + subjects.map(async subject => { + const resource = await store.getResource(subject); + + return { + subject, + title: resource.title, + classes: await getClassNames(resource, store), + }; + }), + ); +} + +/** Hybrid semantic / text search. Needs a server with embeddings enabled. */ +export async function semanticSearch( + store: Store, + query: string, + { + parents, + limit = 10, + textQuery, + }: { parents?: string[]; limit?: number; textQuery?: string } = {}, +): Promise { + if (limit < 1 || limit > 50) { + throw new Error('Limit must be between 1 and 50'); + } + + const results = await store.semanticSearch(query, { + limit, + parents: parents?.map(expandSubject), + text_query: textQuery, + }); + + return Promise.all( + results.map(async ({ subject, chunk }) => { + const resource = await store.getResource(subject); + + return { + subject, + title: resource.title, + classes: await getClassNames(resource, store), + chunk, + }; + }), + ); +} + +/** The classes defined on a drive, as `{ shortname, subject }`. */ +export async function listDriveClasses( + store: Store, + drive: string, +): Promise<{ shortname: string; subject: string }[]> { + const classSubjects = await getClassesOnDrive(drive, store); + + return Promise.all( + classSubjects.map(async subject => { + const resource = await store.getResource(subject); + + return { shortname: resource.title, subject }; + }), + ); +} + +/** + * Sets one property on a resource and, unless `save` is false, saves it. The + * property may be a shortname from the resource's classes or a full URL; tag + * values may be tag names. Returns the stored value and the property it + * resolved to. The in-app assistant passes `save: false` because the person + * reviews its edits before they are saved. + */ +export async function setResourceProperty( + store: Store, + subjectOrRef: string, + property: string, + value: JSONValue, + { save = true }: { save?: boolean } = {}, +): Promise<{ subject: string; property: string; value: JSONValue }> { + const subject = expandSubject(subjectOrRef); + const resource = await store.getResource(subject); + + if (resource.error) { + throw new Error(resource.error.message); + } + + const ctx = await buildClassContext(store, resource.getClasses()); + const info = resolveKey(ctx, property); + const coerced = coerceValueIn(info, value); + + await resource.set(info.subject, coerced); + + if (save) { + await resource.save(); + } + + return { subject, property: info.subject, value: coerced }; +} + +/** + * Creates and saves one resource from a compact JSON-AD object with `@class` + * and `@parent`. `resolved` echoes every shortname → property subject, so a + * silent misresolution is visible to the model. + */ +export async function createResourceFromCompact( + store: Store, + drive: string, + data: Record, +): Promise<{ subject: string; resolved: Record }> { + const { isA, parent, propVals, resolved } = await fromCompact(store, data, { + resolveClass: name => resolveClass(store, drive, name), + }); + + const parentResource = await store.getResource(parent); + const isTableRow = parentResource.hasClasses(dataBrowser.classes.table); + + if (isTableRow) { + propVals[commits.properties.createdAt] ??= Date.now(); + } + + const resource = await store.newResource({ parent, isA, propVals }); + await resource.save(); + + // Rows and ontology members do not belong in the sidebar. + if (!isTableRow && !parentResource.hasClasses(core.classes.ontology)) { + await store.notifyResourceManuallyCreated(resource); + } + + return { subject: resource.subject, resolved }; +} diff --git a/browser/lib/src/class-schema.ts b/browser/lib/src/class-schema.ts new file mode 100644 index 0000000000..8920bf311e --- /dev/null +++ b/browser/lib/src/class-schema.ts @@ -0,0 +1,126 @@ +import { Client } from './client.js'; +import { core, type Core } from './ontologies/core.js'; +import type { Resource } from './resource.js'; +import type { Store } from './store.js'; +import { standardClassAlias } from './standard-class-alias.js'; +import { expandSubject } from './subject-refs.js'; + +/** A class and its properties, in the shape an LLM tool returns. */ +export async function toClassObject(subject: string, store: Store) { + const resource = await store.getResource(subject); + + if (resource.error || resource.loading) { + return `Could not read class: ${subject}`; + } + + return { + subject, + shortname: resource.props.shortname, + description: resource.props.description, + required: await Promise.all( + (resource.props.requires ?? []).map(prop => + toPropertyObject(prop, store), + ), + ), + recommended: await Promise.all( + (resource.props.recommends ?? []).map(prop => + toPropertyObject(prop, store), + ), + ), + }; +} + +/** Every class defined somewhere inside `drive`. */ +export async function getClassesOnDrive( + drive: string, + store: Store, +): Promise { + return store.search('', { + filters: { + [core.properties.isA]: core.classes.class, + }, + parents: [drive], + include: true, + limit: 1000, + }); +} + +/** The titles of a resource's classes, comma separated. */ +export async function getClassNames( + resource: Resource, + store: Store, +): Promise { + const classes = await Promise.all( + resource.getClasses().map(subject => store.getResource(subject)), + ); + + return classes.map(cls => cls.title).join(', '); +} + +/** + * Resolves a class shortname or title to a class subject: a standard alias + * (`folder`, `table`, ...) or a class defined on `drive`. Full URLs and short + * `#refs` pass through (expanded). Throws on unknown or ambiguous names, with + * a hint the model can recover from. + */ +export async function resolveClass( + store: Store, + drive: string, + nameOrRef: string, +): Promise { + const nameOrSubject = expandSubject(nameOrRef); + + if (Client.isValidSubject(nameOrSubject)) { + return nameOrSubject; + } + + const standard = standardClassAlias(nameOrSubject); + + if (standard) return standard; + + const classSubjects = await getClassesOnDrive(drive, store); + const wanted = nameOrSubject.toLowerCase(); + const matches: string[] = []; + + for (const subject of classSubjects) { + const resource = await store.getResource(subject); + const shortname = resource.get(core.properties.shortname) as + | string + | undefined; + + if ( + shortname?.toLowerCase() === wanted || + resource.title.toLowerCase() === wanted + ) { + matches.push(subject); + } + } + + if (matches.length === 1) { + return matches[0]; + } + + if (matches.length > 1) { + throw new Error( + `Ambiguous class "${nameOrSubject}": ${matches.join(', ')}. Use the full class URL.`, + ); + } + + throw new Error( + `Unknown class "${nameOrSubject}". Use get_user_classes to list available classes, or pass a full class URL.`, + ); +} + +async function toPropertyObject(subject: string, store: Store) { + const resource = await store.getResource(subject); + + if (resource.error || resource.loading) { + return `Could not read property: ${subject}`; + } + + return { + subject, + shortname: resource.props.shortname, + datatype: resource.props.datatype, + }; +} diff --git a/browser/lib/src/index.ts b/browser/lib/src/index.ts index cc6165647f..0903ad2c7c 100644 --- a/browser/lib/src/index.ts +++ b/browser/lib/src/index.ts @@ -72,6 +72,11 @@ export * from './pairing.js'; export * from './loro-loader.js'; export * from './page-request-signal.js'; export * from './presence.js'; +export * from './json-ad-compact.js'; +export * from './subject-refs.js'; +export * from './standard-class-alias.js'; +export * from './class-schema.js'; +export * from './assistant-tools.js'; export * from './CryptoProvider.js'; export { ClientDbWorker } from './client-db.js'; export { diff --git a/browser/data-browser/src/chunks/AI/jsonAdCompact.test.ts b/browser/lib/src/json-ad-compact.test.ts similarity index 98% rename from browser/data-browser/src/chunks/AI/jsonAdCompact.test.ts rename to browser/lib/src/json-ad-compact.test.ts index cd7af543c0..3a484dfe39 100644 --- a/browser/data-browser/src/chunks/AI/jsonAdCompact.test.ts +++ b/browser/lib/src/json-ad-compact.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Datatype } from '@tomic/react'; +import { Datatype } from './datatypes.js'; import { addPropertyToContext, coerceValueIn, @@ -9,7 +9,7 @@ import { resolveKey, type ClassContext, type CompactPropertyInfo, -} from './jsonAdCompact'; +} from './json-ad-compact.js'; const statusProperty: CompactPropertyInfo = { subject: 'https://example.com/props/status', diff --git a/browser/data-browser/src/chunks/AI/jsonAdCompact.ts b/browser/lib/src/json-ad-compact.ts similarity index 96% rename from browser/data-browser/src/chunks/AI/jsonAdCompact.ts rename to browser/lib/src/json-ad-compact.ts index b669e76158..3f5d632e6e 100644 --- a/browser/data-browser/src/chunks/AI/jsonAdCompact.ts +++ b/browser/lib/src/json-ad-compact.ts @@ -1,4 +1,3 @@ -// @wc-ignore-file /** * JSON-AD-Compact: the single wire dialect for LLM assistant tool I/O. * See planning/json-ad-compact.md for the format spec and rules. @@ -14,18 +13,15 @@ * place the dialect is implemented — tools and context providers must not * hand-roll their own serialization. Compact is never stored. */ -import { - Client, - Datatype, - core, - dataBrowser, - commits, - type Core, - type JSONValue, - type Resource, - type Store, -} from '@tomic/react'; -import { expandSubject, tryExpandRef } from '@helpers/subjectRefs'; +import { Client } from './client.js'; +import { Datatype } from './datatypes.js'; +import { core, type Core } from './ontologies/core.js'; +import { dataBrowser } from './ontologies/dataBrowser.js'; +import { commits } from './ontologies/commits.js'; +import type { JSONValue } from './value.js'; +import type { Resource } from './resource.js'; +import type { Store } from './store.js'; +import { expandSubject, tryExpandRef } from './subject-refs.js'; export interface CompactPropertyInfo { subject: string; diff --git a/browser/data-browser/src/chunks/AI/standardClassAlias.test.ts b/browser/lib/src/standard-class-alias.test.ts similarity index 77% rename from browser/data-browser/src/chunks/AI/standardClassAlias.test.ts rename to browser/lib/src/standard-class-alias.test.ts index 6f3413da0d..49279fddbf 100644 --- a/browser/data-browser/src/chunks/AI/standardClassAlias.test.ts +++ b/browser/lib/src/standard-class-alias.test.ts @@ -1,6 +1,8 @@ import { expect, it } from 'vitest'; -import { core, dataBrowser, server } from '@tomic/lib'; -import { standardClassAlias } from './standardClassAlias'; +import { core } from './ontologies/core.js'; +import { dataBrowser } from './ontologies/dataBrowser.js'; +import { server } from './ontologies/server.js'; +import { standardClassAlias } from './standard-class-alias.js'; it('resolves standard class names using canonical ontology identifiers', () => { expect(standardClassAlias('File')).toBe(server.classes.file); expect(standardClassAlias('file')).toBe(server.classes.file); diff --git a/browser/data-browser/src/chunks/AI/standardClassAlias.ts b/browser/lib/src/standard-class-alias.ts similarity index 76% rename from browser/data-browser/src/chunks/AI/standardClassAlias.ts rename to browser/lib/src/standard-class-alias.ts index c793b42eee..416156277c 100644 --- a/browser/data-browser/src/chunks/AI/standardClassAlias.ts +++ b/browser/lib/src/standard-class-alias.ts @@ -1,5 +1,7 @@ -// @wc-ignore-file -import { core, dataBrowser, server } from '@tomic/lib'; +import { core } from './ontologies/core.js'; +import { dataBrowser } from './ontologies/dataBrowser.js'; +import { server } from './ontologies/server.js'; + const aliases: Record = { file: server.classes.file, folder: dataBrowser.classes.folder, diff --git a/browser/data-browser/src/helpers/subjectRefs.test.ts b/browser/lib/src/subject-refs.test.ts similarity index 95% rename from browser/data-browser/src/helpers/subjectRefs.test.ts rename to browser/lib/src/subject-refs.test.ts index d132eb600d..af7dc27a62 100644 --- a/browser/data-browser/src/helpers/subjectRefs.test.ts +++ b/browser/lib/src/subject-refs.test.ts @@ -1,11 +1,10 @@ -// @wc-ignore-file import { describe, expect, it } from 'vitest'; import { expandSubject, shortenRefsDeep, shortenSubject, tryExpandRef, -} from './subjectRefs'; +} from './subject-refs.js'; const DID_A = `did:ad:${'QyJIHE1kP9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}`; const DID_B = `did:ad:${'QyJIHE1kZZbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'}`; @@ -90,10 +89,10 @@ it('retains emitted refs when the module reloads in the same tab', async () => { setItem: (key: string, value: string) => storage.set(key, value), }); vi.resetModules(); - const first = await import('./subjectRefs'); + const first = await import('./subject-refs.js'); const ref = first.shortenSubject(DID_A); vi.resetModules(); - const reopened = await import('./subjectRefs'); + const reopened = await import('./subject-refs.js'); expect(reopened.expandSubject(ref)).toBe(DID_A); vi.unstubAllGlobals(); }); diff --git a/browser/data-browser/src/helpers/subjectRefs.ts b/browser/lib/src/subject-refs.ts similarity index 98% rename from browser/data-browser/src/helpers/subjectRefs.ts rename to browser/lib/src/subject-refs.ts index 3c7a017424..e9827d7d4f 100644 --- a/browser/data-browser/src/helpers/subjectRefs.ts +++ b/browser/lib/src/subject-refs.ts @@ -1,4 +1,3 @@ -// @wc-ignore-file /** * Short refs for `did:ad:` subjects in LLM tool I/O (see * planning/json-ad-compact.md). A full DID is ~90 chars of high-entropy @@ -14,7 +13,7 @@ * expanded at the tool boundary and in link rendering only. */ -import { identifierBody } from '@tomic/lib'; +import { identifierBody } from './subject.js'; /** Plain resource subjects only, in either scheme; agent / commit / blob / * node subjects stay untouched. */ diff --git a/browser/mcp/.gitignore b/browser/mcp/.gitignore new file mode 100644 index 0000000000..5e56e040ec --- /dev/null +++ b/browser/mcp/.gitignore @@ -0,0 +1 @@ +/bin diff --git a/browser/mcp/README.md b/browser/mcp/README.md new file mode 100644 index 0000000000..c1accd1e8f --- /dev/null +++ b/browser/mcp/README.md @@ -0,0 +1,77 @@ +# @tomic/mcp + +An [MCP](https://modelcontextprotocol.io) server that lets an LLM client +(Claude Code, Claude Desktop, Cursor, ...) read and edit your Atomic Data. + +It runs on your own machine and signs its edits with your Agent's key, so every +change is an ordinary signed commit, exactly as if you made it in the app. The +key never leaves your machine. + +## Setup + +1. In the app, open your account settings (`/app/agent`) and, under + **Account recovery**, reveal and copy your agent secret. +2. Note the address of the server your drives live on (the origin of the app, + e.g. `https://atomicdata.dev` or your own node). +3. Add the server to your client. + +Claude Code: + +```sh +claude mcp add atomic \ + -e ATOMIC_SERVER_URL=https://atomicdata.dev \ + -e ATOMIC_AGENT_SECRET= \ + -- npx -y @tomic/mcp +``` + +Claude Desktop, Cursor and others (`mcpServers` in their config file): + +```json +{ + "mcpServers": { + "atomic": { + "command": "npx", + "args": ["-y", "@tomic/mcp"], + "env": { + "ATOMIC_SERVER_URL": "https://atomicdata.dev", + "ATOMIC_AGENT_SECRET": "" + } + } + } +} +``` + +Treat the secret like a password: anyone who has it can act as you. + +### Environment variables + +| Variable | Required | Meaning | +| --- | --- | --- | +| `ATOMIC_SERVER_URL` | yes | The server your drives live on. | +| `ATOMIC_AGENT_SECRET` | for writes | Your Agent secret. Without it, only public data can be read. | +| `ATOMIC_DRIVE` | no | The drive tools default to. Defaults to the drive in your secret. | +| `ATOMIC_READ_ONLY` | no | `true` registers only the read tools. | + +## Tools + +| Tool | What it does | +| --- | --- | +| `list_drives` | Your drives, and which one is the default. | +| `get_resource` | Reads resources as compact JSON-AD; documents and meetings include their text. | +| `search` | Full-text search. | +| `semantic_search` | Search by meaning (needs a server with embeddings). | +| `query` | Finds resources by property values, e.g. all tasks with status "done". | +| `get_user_classes` | The custom classes on the drive. | +| `get_schema` | The properties of a class. | +| `create_resource` | Creates one or many resources. | +| `edit_resource` | Sets one property. | +| `delete_resource` | Deletes a resource (never a whole drive). | + +These are the same verbs the in-app assistant uses; both call the +implementations in `@tomic/lib` (`assistant-tools.ts`). Results use +JSON-AD-Compact (property shortnames, `#ref` short subjects), see +`planning/json-ad-compact.md`. + +Not yet: editing a document's text, and a hosted (remote) MCP endpoint that +claude.ai can connect to without a local process. See +`planning/mcp-endpoint.md`. diff --git a/browser/mcp/package.json b/browser/mcp/package.json new file mode 100644 index 0000000000..674e8302bd --- /dev/null +++ b/browser/mcp/package.json @@ -0,0 +1,46 @@ +{ + "name": "@tomic/mcp", + "version": "0.41.0-beta.7", + "author": "Joep Meindertsma", + "homepage": "https://docs.atomicdata.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/atomicdata-dev/atomic-server.git" + }, + "bugs": { + "url": "https://github.com/atomicdata-dev/atomic-server/issues" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "@tomic/lib": "workspace:*", + "loro-crdt": "^1.12.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "typescript": "^6.0.3", + "vitest": "^4.1.7" + }, + "description": "MCP server that lets an LLM client read and edit Atomic Data", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "files": [ + "bin" + ], + "scripts": { + "build": "tsc", + "format-check": "oxfmt -c ../.oxfmtrc.json --check ./src", + "format": "oxfmt -c ../.oxfmtrc.json ./src", + "lint": "oxlint -c ../.oxlintrc.json . && pnpm format-check", + "lint-fix": "oxlint -c ../.oxlintrc.json --fix . && pnpm format", + "prepublishOnly": "pnpm run build && pnpm run lint", + "test": "vitest run", + "typecheck": "pnpm exec tsc --noEmit" + }, + "bin": { + "atomic-mcp": "./bin/index.js" + }, + "type": "module" +} diff --git a/browser/mcp/src/document-text.test.ts b/browser/mcp/src/document-text.test.ts new file mode 100644 index 0000000000..4e880d5a77 --- /dev/null +++ b/browser/mcp/src/document-text.test.ts @@ -0,0 +1,62 @@ +import { LoroDoc, LoroList, LoroMap, LoroText } from 'loro-crdt'; +import { expect, it } from 'vitest'; +import { documentText } from './document-text.js'; + +/** Builds a node the way loro-prosemirror stores one. */ +function node( + parent: LoroList, + nodeName: string, + children: (string | ((list: LoroList) => void))[], + attributes: Record = {}, +) { + const map = parent.pushContainer(new LoroMap()); + map.set('nodeName', nodeName); + const attrs = map.setContainer('attributes', new LoroMap()); + + for (const [key, value] of Object.entries(attributes)) attrs.set(key, value); + + const list = map.setContainer('children', new LoroList()); + + for (const child of children) { + if (typeof child === 'string') { + list.pushContainer(new LoroText()).insert(0, child); + } else { + child(list); + } + } +} + +it('reads headings, paragraphs and nested list items', () => { + const doc = new LoroDoc(); + const root = doc.getMap('doc'); + root.set('nodeName', 'doc'); + const children = root.setContainer('children', new LoroList()); + + node(children, 'heading', ['Agenda'], { level: 2 }); + node(children, 'paragraph', ['Welcome everyone.']); + node(children, 'bulletList', [ + list => + node(list, 'listItem', [ + l => node(l, 'paragraph', ['Budget']), + l => + node(l, 'bulletList', [ + inner => + node(inner, 'listItem', [p => node(p, 'paragraph', ['Q4'])]), + ]), + ]), + ]); + node(children, 'taskList', [ + list => + node(list, 'taskItem', [l => node(l, 'paragraph', ['Send notes'])], { + checked: true, + }), + ]); + + expect(documentText(doc)).toBe( + '## Agenda\n\nWelcome everyone.\n- Budget\n - Q4\n- [x] Send notes', + ); +}); + +it('returns an empty string for a document without a body', () => { + expect(documentText(new LoroDoc())).toBe(''); +}); diff --git a/browser/mcp/src/document-text.ts b/browser/mcp/src/document-text.ts new file mode 100644 index 0000000000..45b8e6ee11 --- /dev/null +++ b/browser/mcp/src/document-text.ts @@ -0,0 +1,101 @@ +import type { LoroDoc } from 'loro-crdt'; + +/** + * Reads the rich-text body of a document or meeting as Markdown-ish plain text. + * + * The body is a loro-prosemirror tree in the Loro map `doc`: every node is a + * map with `nodeName`, `attributes` and `children`, and text runs are + * `LoroText` children. Walking that tree directly needs no editor schema, + * which lives in the data-browser and pulls in TipTap. Marks (bold, links) + * are dropped; block structure is kept. + */ +export function documentText(doc: LoroDoc): string { + const root = doc.getMap('doc'); + + if (root.get('nodeName') === undefined) { + return ''; + } + + const lines: string[] = []; + walk(root.toJSON() as LoroNodeJson, lines, ''); + + return lines + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +interface LoroNodeJson { + nodeName?: string; + attributes?: Record; + children?: (LoroNodeJson | string)[]; +} + +const inlineText = (node: LoroNodeJson): string => + (node.children ?? []) + .map(child => (typeof child === 'string' ? child : inlineText(child))) + .join(''); + +function walk(node: LoroNodeJson, lines: string[], indent: string): void { + const children = node.children ?? []; + + switch (node.nodeName) { + case 'heading': { + const level = Number(node.attributes?.level ?? 1); + lines.push('', `${'#'.repeat(level)} ${inlineText(node)}`, ''); + + return; + } + + case 'paragraph': + lines.push(`${indent}${inlineText(node)}`); + + return; + + case 'codeBlock': + lines.push('```', inlineText(node), '```'); + + return; + + case 'horizontalRule': + lines.push('---'); + + return; + + case 'listItem': + listItem(node, lines, indent, ''); + + return; + + case 'taskItem': + listItem(node, lines, indent, node.attributes?.checked ? '[x] ' : '[ ] '); + + return; + + default: + for (const child of children) { + if (typeof child === 'string') { + lines.push(`${indent}${child}`); + } else { + walk(child, lines, indent); + } + } + } +} + +/** The first child is the item's own line; later ones (nested lists) indent. */ +function listItem( + node: LoroNodeJson, + lines: string[], + indent: string, + checkbox: string, +): void { + const [first, ...rest] = node.children ?? []; + const firstText = + typeof first === 'string' ? first : first ? inlineText(first) : ''; + lines.push(`${indent}- ${checkbox}${firstText}`); + + for (const child of rest) { + if (typeof child !== 'string') walk(child, lines, `${indent} `); + } +} diff --git a/browser/mcp/src/index.ts b/browser/mcp/src/index.ts new file mode 100644 index 0000000000..4018c41ace --- /dev/null +++ b/browser/mcp/src/index.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env node +/** + * `atomic-mcp`: an MCP server over stdio that reads and edits Atomic Data as + * the Agent whose secret it is given. See ../README.md for client setup. + */ +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { Agent, Store, enableLoro } from '@tomic/lib'; +import { createAtomicMcpServer } from './server.js'; + +const serverUrl = process.env.ATOMIC_SERVER_URL; +const secret = process.env.ATOMIC_AGENT_SECRET; + +// stdout is the protocol channel: everything human-readable goes to stderr, +// including the library's own console logging. +console.log = console.info = console.debug = console.error; + +const log = (message: string) => + process.stderr.write(`[atomic-mcp] ${message}\n`); + +if (!serverUrl) { + log( + 'Set ATOMIC_SERVER_URL to the server your drives live on, e.g. https://atomicdata.dev', + ); + process.exit(1); +} + +const agent = secret ? await Agent.fromSecret(secret) : undefined; +const drive = + process.env.ATOMIC_DRIVE ?? agent?.initialDrive ?? agent?.privateDrive; + +if (!drive) { + log( + 'Set ATOMIC_DRIVE to a drive subject, or ATOMIC_AGENT_SECRET to your agent secret (in the app: /app/agent, Account recovery).', + ); + process.exit(1); +} + +// Resources arrive as Loro snapshots; without Loro they never finish loading. +await enableLoro(); + +const store = new Store({ serverUrl, agent }); +store.setServerConnected(true); + +const mcp = createAtomicMcpServer({ + store, + drive, + allowWrites: !!agent && process.env.ATOMIC_READ_ONLY !== 'true', +}); + +await mcp.connect(new StdioServerTransport()); +log( + `Connected to ${serverUrl} as ${agent?.subject ?? 'a public reader'}, default drive ${drive}`, +); diff --git a/browser/mcp/src/server.ts b/browser/mcp/src/server.ts new file mode 100644 index 0000000000..8f2b495003 --- /dev/null +++ b/browser/mcp/src/server.ts @@ -0,0 +1,377 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + createResourceFromCompact, + dataBrowser, + expandSubject, + listDriveClasses, + queryResources, + readResourceCompact, + semanticSearch, + server as serverOntology, + setResourceProperty, + shortenRefsDeep, + shortenSubject, + textSearch, + toClassObject, + type JSONValue, + type Store, +} from '@tomic/lib'; +import { z } from 'zod'; +import { documentText } from './document-text.js'; + +export interface AtomicMcpOptions { + store: Store; + /** The drive tools default to: class lookup, search scope. */ + drive: string; + /** Set to false to register only the read tools. */ + allowWrites?: boolean; +} + +type ToolResult = { + content: { type: 'text'; text: string }[]; + isError?: boolean; +}; + +const ok = (value: unknown): ToolResult => ({ + content: [ + { + type: 'text', + text: + typeof value === 'string' + ? value + : JSON.stringify(shortenRefsDeep(value), null, 2), + }, + ], +}); + +const fail = (error: unknown): ToolResult => ({ + content: [ + { + type: 'text', + text: `Error: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, +}); + +/** Runs a tool body, turning a throw into an MCP tool error the model reads. */ +const run = + (body: (args: A) => Promise) => + async (args: A): Promise => { + try { + return ok(await body(args)); + } catch (error) { + return fail(error); + } + }; + +const hasDocumentBody = (classes: string[]) => + classes.includes(dataBrowser.classes.documentV2) || + classes.includes(dataBrowser.classes.meeting); + +/** + * Builds an MCP server whose tools read and edit Atomic Data through `store`, + * as whatever Agent the store is signed in with. Writes are ordinary signed + * commits, exactly as if the person made them in the app. + */ +export function createAtomicMcpServer({ + store, + drive, + allowWrites = true, +}: AtomicMcpOptions): McpServer { + const mcp = new McpServer( + { name: 'atomic', version: '0.41.0' }, + { + instructions: `Tools for reading and editing Atomic Data (a graph of resources, each with a subject such as did:ad:… and properties). Results use compact JSON-AD: property shortnames as keys, "@id", "@class" and "@parent" as structural keys, and short refs like #AbCd1234 for subjects, which every tool accepts back. The default drive is ${shortenSubject(drive)}. Start with list_drives or search, read resources with get_resource, and use get_user_classes / get_schema before creating resources of a custom class.`, + }, + ); + + mcp.registerTool( + 'list_drives', + { + title: 'List drives', + description: + 'List the drives (top-level workspaces) the signed-in agent has, and which one is the default for the other tools.', + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + run(async () => { + const agent = store.getAgent(); + const subjects = new Set([drive]); + + if (agent?.subject) { + const agentResource = await store.getResource(agent.subject); + const drives = agentResource.get(serverOntology.properties.drives); + + if (Array.isArray(drives)) { + for (const subject of drives) subjects.add(String(subject)); + } + } + + return Promise.all( + [...subjects].map(async subject => { + const resource = await store.getResource(subject); + + return { + subject, + name: resource.error ? undefined : resource.title, + default: subject === drive, + ...(resource.error ? { error: resource.error.message } : {}), + }; + }), + ); + }), + ); + + mcp.registerTool( + 'get_resource', + { + title: 'Read resources', + description: + 'Read one or more resources by subject (or #ref). Returns compact JSON-AD with a one-line `_schema` per class, and for documents and meetings their text as `_documentText`. Children of a folder or drive can be found with query (where parent = subject) or search.', + inputSchema: { + subjects: z + .array(z.string()) + .min(1) + .describe('Subjects or #refs of the resources to read.'), + includeCommitData: z + .boolean() + .optional() + .describe('Include the last commit (author and time).'), + }, + annotations: { readOnlyHint: true }, + }, + run(async ({ subjects, includeCommitData }) => { + const result: Record = {}; + + for (const subjectOrRef of subjects) { + try { + const entry = await readResourceCompact(store, subjectOrRef, { + includeCommitData, + }); + const resource = await store.getResource(expandSubject(subjectOrRef)); + const loro = resource.getLoroDoc(); + + if (hasDocumentBody(resource.getClasses()) && loro) { + entry._documentText = documentText(loro); + } + + result[resource.subject] = entry; + } catch (error) { + result[subjectOrRef] = `Error: ${(error as Error).message}`; + } + } + + return result; + }), + ); + + mcp.registerTool( + 'search', + { + title: 'Search', + description: + 'Full-text search for resources by words in their name, description or other text. Scoped to the default drive unless `parents` is given.', + inputSchema: { + query: z.string().describe('Words to search for.'), + parents: z + .array(z.string()) + .optional() + .describe('Drives or folders to search in (subjects or #refs).'), + limit: z.number().int().min(1).max(50).optional(), + }, + annotations: { readOnlyHint: true }, + }, + run(({ query, parents, limit }) => + textSearch(store, query, { parents: parents ?? [drive], limit }), + ), + ); + + mcp.registerTool( + 'semantic_search', + { + title: 'Semantic search', + description: + 'Search by meaning rather than exact words. Returns the first matching chunk of each resource. Needs a server with embeddings enabled; fall back to search if it errors.', + inputSchema: { + query: z.string().describe('What you are looking for.'), + text_query: z + .string() + .optional() + .describe('Exact words to bias the results towards.'), + parents: z.array(z.string()).optional(), + limit: z.number().int().min(1).max(50).optional(), + }, + annotations: { readOnlyHint: true }, + }, + run(({ query, text_query, parents, limit }) => + semanticSearch(store, query, { + parents: parents ?? [drive], + limit, + textQuery: text_query, + }), + ), + ); + + mcp.registerTool( + 'query', + { + title: 'Query by property', + description: + 'Find resources with specific property values, like a SQL WHERE. With `class` set, where/select take property shortnames and tag names (e.g. class: "task", where: [{property: "status", value: "done"}]) and an isA filter is added. Without `class`, properties must be full URLs, e.g. where: [{property: "https://atomicdata.dev/properties/parent", value: "did:ad:…"}] lists the children of a folder. Results are not sorted.', + inputSchema: { + class: z.string().optional(), + where: z.array( + z.object({ + property: z.string(), + value: z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.string()), + ]), + }), + ), + select: z + .array(z.string()) + .optional() + .describe('Properties to include. Defaults to name.'), + limit: z.number().int().min(1).max(200).optional(), + }, + annotations: { readOnlyHint: true }, + }, + run(args => + queryResources(store, drive, { + ...args, + where: args.where.map(({ property, value }) => ({ + property, + value: value as JSONValue, + })), + }), + ), + ); + + mcp.registerTool( + 'get_user_classes', + { + title: 'List classes', + description: + 'List the classes (custom types, like "task" or "deal") defined on the default drive.', + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + run(() => listDriveClasses(store, drive)), + ); + + mcp.registerTool( + 'get_schema', + { + title: 'Get class schema', + description: + 'The required and recommended properties of a class, with their shortnames and datatypes.', + inputSchema: { + subject: z.string().describe('The class subject or #ref.'), + }, + annotations: { readOnlyHint: true }, + }, + run(({ subject }) => toClassObject(expandSubject(subject), store)), + ); + + if (!allowWrites) { + return mcp; + } + + mcp.registerTool( + 'edit_resource', + { + title: 'Edit a property', + description: + 'Set one property on a resource and save it. `property` is a shortname from the resource\'s schema (e.g. "status") or a full property URL; select values take tag names, dates take ISO strings.', + inputSchema: { + subject: z.string(), + property: z.string(), + value: z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.string()), + ]), + }, + annotations: { readOnlyHint: false, destructiveHint: false }, + }, + run(({ subject, property, value }) => + setResourceProperty(store, subject, property, value), + ), + ); + + mcp.registerTool( + 'create_resource', + { + title: 'Create resources', + description: + 'Create one or more resources from compact JSON-AD. Each object needs "@class" (a shortname like "folder", "document", "table", a class from get_user_classes, or a full URL) and "@parent" (a drive, folder or table subject), plus property shortnames as keys, e.g. {"@class": "task", "@parent": "#AbCd1234", "name": "Call Anna", "status": "todo"}. Never pass "@id". Pass an array to create many at once.', + inputSchema: { + resources: z.array(z.record(z.string(), z.unknown())).min(1).max(200), + }, + annotations: { readOnlyHint: false, destructiveHint: false }, + }, + run(async ({ resources }) => { + const created: string[] = []; + const errors: string[] = []; + const resolved: Record = {}; + + for (const [index, data] of resources.entries()) { + try { + const result = await createResourceFromCompact( + store, + drive, + data as Record, + ); + created.push(result.subject); + Object.assign(resolved, result.resolved); + } catch (error) { + errors.push(`Item ${index}: ${(error as Error).message}`); + } + } + + if (created.length === 0) { + throw new Error(errors.join('\n')); + } + + return { + created, + ...(Object.keys(resolved).length > 0 ? { resolved } : {}), + ...(errors.length > 0 ? { errors } : {}), + }; + }), + ); + + mcp.registerTool( + 'delete_resource', + { + title: 'Delete a resource', + description: + 'Delete a resource and everything inside it (a folder deletes its contents). This cannot be undone from here; confirm with the person first unless they clearly asked for it.', + inputSchema: { subject: z.string() }, + annotations: { readOnlyHint: false, destructiveHint: true }, + }, + run(async ({ subject }) => { + const resource = await store.getResource(expandSubject(subject)); + + if (resource.error) { + throw new Error(resource.error.message); + } + + if (resource.hasClasses(serverOntology.classes.drive)) { + throw new Error('Deleting a whole drive is not allowed from MCP.'); + } + + const title = resource.title; + await resource.destroy(); + + return `Deleted ${title} (${shortenSubject(resource.subject)}).`; + }), + ); + + return mcp; +} diff --git a/browser/mcp/tsconfig.json b/browser/mcp/tsconfig.json new file mode 100644 index 0000000000..775aacfa0b --- /dev/null +++ b/browser/mcp/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "outDir": "./bin", + "rootDir": "./src", + "target": "ES2023", + "moduleResolution": "nodeNext", + "module": "nodeNext", + "strict": true, + "skipLibCheck": true, + "declaration": false, + "types": ["node"] + }, + "include": ["./src"], + "exclude": ["./src/**/*.test.ts"] +} diff --git a/browser/pnpm-lock.yaml b/browser/pnpm-lock.yaml index 80b860a4a2..ff07dccb1f 100644 --- a/browser/pnpm-lock.yaml +++ b/browser/pnpm-lock.yaml @@ -552,6 +552,31 @@ importers: specifier: ^4.1.7 version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@30.1.0(@noble/hashes@2.2.0))(vite@8.2.2(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.30.0 + version: 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) + '@tomic/lib': + specifier: workspace:* + version: link:../lib + loro-crdt: + specifier: ^1.12.1 + version: 1.12.1 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@30.1.0(@noble/hashes@2.2.0))(vite@8.2.2(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + plugin: dependencies: '@tomic/lib': @@ -713,12 +738,6 @@ packages: resolution: {integrity: sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - '@automerge/automerge-repo@2.5.6': - resolution: {integrity: sha512-ZXM6TOAwm192g3+zIxYvlB+Z3O00NP+psErOwvbSype8fFO+dhc8tB/jPwfZJqmn1ULUz5w7gssKEynwcYFRSA==} - - '@automerge/automerge@3.4.1': - resolution: {integrity: sha512-zsZpbs/iDPvp+ZojIYd+gxmbcPVz2Xbkcx778G8zrt3E0zS+6saHJOm666lOuZyNRlTV4wHw9qzGTKueedeCsQ==} - '@axe-core/playwright@4.11.3': resolution: {integrity: sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==} peerDependencies: @@ -10318,21 +10337,6 @@ snapshots: is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.2 - '@automerge/automerge-repo@2.5.6': - dependencies: - '@automerge/automerge': 3.4.1 - bs58check: 3.0.1 - cbor-x: 1.6.6 - debug: 4.4.3(supports-color@10.2.2) - eventemitter3: 5.0.4 - fast-sha256: 1.3.0 - uuid: 9.0.1 - xstate: 5.32.6 - transitivePeerDependencies: - - supports-color - - '@automerge/automerge@3.4.1': {} - '@axe-core/playwright@4.11.3(playwright-core@1.63.0(patch_hash=51ccaaf15d34f7f1a82e936ef746184b5ff972a17b05ddff80d43daf6d89d86b))': dependencies: axe-core: 4.11.4 diff --git a/planning/mcp-endpoint.md b/planning/mcp-endpoint.md index 086322f767..310af34278 100644 --- a/planning/mcp-endpoint.md +++ b/planning/mcp-endpoint.md @@ -1,6 +1,10 @@ # Atomic as an MCP server -> **Status:** Proposal (2026-09). Nothing in this file is implemented. +> **Status:** Steps 1 and 2 of the sequencing below are in (2026-09): the +> data verbs live in `@tomic/lib` (`assistant-tools.ts`, with +> `json-ad-compact.ts`, `subject-refs.ts`, `class-schema.ts`), the in-app +> assistant calls them, and `browser/mcp` (`@tomic/mcp`) is the local stdio +> server. Steps 3 to 5 (hosted, OAuth) are not started. > Companion to [`actions.md`](./actions.md) (one verb list, many surfaces), > [`json-ad-compact.md`](./json-ad-compact.md) (the wire dialect), > [`atomic-lib-runtime.md`](./atomic-lib-runtime.md) (`AtomicNode`), @@ -143,21 +147,21 @@ module. Long-term the graph verbs belong on `AtomicNode`; UI verbs do not. ## Sequencing -1. **Extract headless tools** from `useAtomicTools.ts` (and +1. [x] **Extract headless tools** from `useAtomicTools.ts` (and `jsonAdCompact.ts`) so they take a `Store`, not a hook. Assistant keeps working; this is the shared library MCP will call. Cheap, unblocks everything, and is the [`actions.md`](./actions.md) "fifth enumeration" fix even if MCP slips. -2. **Local stdio MCP.** Node script or small binary. Agent secret from +2. [x] **Local stdio MCP.** Node script or small binary. Agent secret from env / config (same family as `/app/token`). Read + write. This is the Cursor/Claude Desktop config people actually add. Highest ROI; no AS. -3. **Server `format=compact`.** json-ad-compact phase 4. Needed before a +3. [ ] **Server `format=compact`.** json-ad-compact phase 4. Needed before a Rust remote handler is worth writing. -4. **Remote Streamable HTTP, read-only.** RFC 9728 metadata, OAuth 2.1 +4. [ ] **Remote Streamable HTTP, read-only.** RFC 9728 metadata, OAuth 2.1 against Atomic-as-AS (#1275 reopened) *or* the operator IdP if #1310 has already advertised one. Writes return a clear "use local MCP / issued agent not yet" error, not a silent server-side save. -5. **Remote writes as issued agent.** Same key-on-node pattern as plugin +5. [ ] **Remote writes as issued agent.** Same key-on-node pattern as plugin unattended runs. Consent scopes: at least drive + read/write. `signer` is the issued DID. The user's root never touches the commit. From 6e606ffe3d57c0e06e7306c3602d0a0b76c8c2ea Mon Sep 17 00:00:00 2001 From: Joep Meindertsma Date: Mon, 28 Sep 2026 15:28:05 +0000 Subject: [PATCH 2/7] Note the MCP server in the changelog --- browser/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index 6a2ed99b3f..9f63994a12 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -4,6 +4,14 @@ This changelog covers all five packages, as they are (for now) updated as a whol ## UNRELEASED +- New package `@tomic/mcp`: an MCP server that lets Claude Code, Claude + Desktop, Cursor and other MCP clients read and edit your Atomic Data. It runs + locally and signs edits with your own Agent key. `@tomic/lib` now exports the + data verbs behind it and behind the in-app assistant (`readResourceCompact`, + `queryResources`, `textSearch`, `semanticSearch`, `listDriveClasses`, + `setResourceProperty`, `createResourceFromCompact`), plus the compact JSON-AD + helpers (`toCompact`, `fromCompact`, ...) and short subject refs + (`shortenSubject`, `expandSubject`) that used to live in the data-browser. - Turning workspace sync off says what is actually in the way. All three of its preconditions used to answer with "Open this drive with local storage available before disconnecting", so someone signed out, or on a server this From 9cd7af194606ab38fbe19e2809d1eecb87c46bea Mon Sep 17 00:00:00 2001 From: Joep Meindertsma Date: Mon, 28 Sep 2026 17:19:31 +0000 Subject: [PATCH 3/7] Connect the MCP server with its own key instead of your secret atomic-mcp now makes a key on the machine it runs on and prints a link. In the app (/app/connect-agent) the person picks which drives it may reach and whether it may edit, then clicks Allow. Account settings lists connected apps with a Revoke button. Only an agent may edit its own Agent resource, so the grant is not recorded there: the ACLs on the shared drives are the record. The app adds the key to read (and write), the MCP finds its access by searching for resources that list it, and revoking removes it everywhere, including from what it created. The app remembers which apps were connected on the private drive, so settings can list them. ATOMIC_AGENT_SECRET still works for scripts and CI. --- browser/CHANGELOG.md | 7 +- .../src/components/ConnectedAppsCard.tsx | 184 +++++++++++++ .../data-browser/src/helpers/connectedApps.ts | 42 +++ .../src/routes/ConnectAgentRoute.tsx | 257 ++++++++++++++++++ browser/data-browser/src/routes/Router.tsx | 2 + .../data-browser/src/routes/SettingsAgent.tsx | 15 +- browser/data-browser/src/routes/paths.tsx | 2 + browser/lib/src/agent-grants.test.ts | 74 +++++ browser/lib/src/agent-grants.ts | 106 ++++++++ browser/lib/src/index.ts | 1 + browser/mcp/README.md | 117 ++++---- browser/mcp/src/index.ts | 142 ++++++++-- browser/mcp/src/local-agent.ts | 109 ++++++++ browser/mcp/src/server.ts | 50 ++-- planning/mcp-endpoint.md | 11 +- 15 files changed, 1021 insertions(+), 98 deletions(-) create mode 100644 browser/data-browser/src/components/ConnectedAppsCard.tsx create mode 100644 browser/data-browser/src/helpers/connectedApps.ts create mode 100644 browser/data-browser/src/routes/ConnectAgentRoute.tsx create mode 100644 browser/lib/src/agent-grants.test.ts create mode 100644 browser/lib/src/agent-grants.ts create mode 100644 browser/mcp/src/local-agent.ts diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index 9f63994a12..400c8c9464 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -6,7 +6,12 @@ This changelog covers all five packages, as they are (for now) updated as a whol - New package `@tomic/mcp`: an MCP server that lets Claude Code, Claude Desktop, Cursor and other MCP clients read and edit your Atomic Data. It runs - locally and signs edits with your own Agent key. `@tomic/lib` now exports the + locally with its own key: `atomic-mcp connect` opens a link in the app + (`/app/connect-agent`) where you pick the drives it may reach and whether it + may edit, so your own secret is never shared. Account settings lists + connected apps under **Connected apps**, with Revoke. The grants are the ACLs + themselves (`grantAgent`, `grantsTo`, `revokeAgent` in `@tomic/lib`). + `@tomic/lib` now also exports the data verbs behind it and behind the in-app assistant (`readResourceCompact`, `queryResources`, `textSearch`, `semanticSearch`, `listDriveClasses`, `setResourceProperty`, `createResourceFromCompact`), plus the compact JSON-AD diff --git a/browser/data-browser/src/components/ConnectedAppsCard.tsx b/browser/data-browser/src/components/ConnectedAppsCard.tsx new file mode 100644 index 0000000000..31f4bbb3c8 --- /dev/null +++ b/browser/data-browser/src/components/ConnectedAppsCard.tsx @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + core, + grantsTo, + revokeAgent, + useResource, + useStore, + type AgentGrant, +} from '@tomic/react'; +import { styled } from 'styled-components'; +import { Button } from './Button'; +import { Card } from './Card'; +import { ErrorLook } from './ErrorLook'; +import { + forgetConnectedApp, + listConnectedApps, +} from '../helpers/connectedApps'; + +/** + * The apps the person let use their data (see ConnectAgentRoute), each with + * what it can reach and a Revoke button. What it can reach is read from the + * ACLs, so the list cannot drift from what the app can actually open. + */ +export function ConnectedAppsCard({ home }: { home: string }) { + const store = useStore(); + const [apps, setApps] = useState(); + + const refresh = useCallback(() => { + listConnectedApps(store, home) + .then(setApps) + .catch(() => setApps([])); + }, [store, home]); + + useEffect(refresh, [refresh]); + + if (!apps) { + return null; + } + + if (apps.length === 0) { + return ( + + No apps yet. An AI assistant connected through the Atomic MCP server + shows up here. + + ); + } + + return ( + + + {apps.map(subject => ( + + ))} + + + ); +} + +function ConnectedApp({ + subject, + home, + onRevoked, +}: { + subject: string; + home: string; + onRevoked: () => void; +}) { + const store = useStore(); + const [name, setName] = useState(); + const [grants, setGrants] = useState(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + useEffect(() => { + // Only what the person shared. What the app created itself it can also + // edit, and revoking takes that away too, but listing it here is noise. + grantsTo(store, subject) + .then(all => setGrants(all.filter(grant => grant.read))) + .catch(() => setGrants([])); + + // The app names itself on its own Agent resource. Ask the server: a copy + // cached on this device may predate the app setting its name. + store + .fetchResourceFromServer(subject, { noWebSocket: true }) + .then(profile => + setName(profile.get(core.properties.name) as string | undefined), + ) + .catch(() => undefined); + }, [store, subject]); + + async function handleRevoke() { + setBusy(true); + setError(undefined); + + try { + const report = await revokeAgent(store, subject); + + if (report.failed.length > 0) { + setError( + `Still has access to ${report.failed.length} ${ + report.failed.length === 1 ? 'resource' : 'resources' + }: ${report.failed.map(f => f.reason).join('; ')}`, + ); + } else { + await forgetConnectedApp(store, home, subject); + onRevoked(); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + + setBusy(false); + } + + const label = name ?? `App ${subject.slice(-8)}`; + const canEdit = grants?.some(grant => grant.write); + + return ( + +
+ {label} + + {grants === undefined + ? '…' + : grants.length === 0 + ? 'No access left' + : grants.map((grant, i) => ( + + {i > 0 && ', '} + + + ))} + {canEdit ? ' · can edit' : grants?.length ? ' · read only' : ''} + + {error && {error}} +
+ +
+ ); +} + +function TargetName({ subject }: { subject: string }) { + const resource = useResource(subject); + + return <>{resource.loading ? '…' : resource.title}; +} + +const List = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +const Item = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +`; + +const Details = styled.div` + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +`; + +const Muted = styled.span` + color: ${p => p.theme.colors.textLight}; +`; diff --git a/browser/data-browser/src/helpers/connectedApps.ts b/browser/data-browser/src/helpers/connectedApps.ts new file mode 100644 index 0000000000..a12ff53580 --- /dev/null +++ b/browser/data-browser/src/helpers/connectedApps.ts @@ -0,0 +1,42 @@ +import { dataBrowser, type Store } from '@tomic/react'; + +const APPS = dataBrowser.properties.resources; + +/** + * Which apps the person connected (an AI assistant through MCP, for now), kept + * as a list of Agent subjects on their private drive so account settings can + * list them. What each app may reach is not kept here: the ACLs on the shared + * resources are the record (see `grantsTo` in @tomic/lib). + */ +export async function listConnectedApps( + store: Store, + home: string, +): Promise { + const drive = await store.getResource(home); + + return (drive.get(APPS) as string[] | undefined) ?? []; +} + +export async function rememberConnectedApp( + store: Store, + home: string, + agent: string, +): Promise { + const drive = await store.getResource(home); + drive.push(APPS, [agent], true); + await drive.save(); +} + +export async function forgetConnectedApp( + store: Store, + home: string, + agent: string, +): Promise { + const drive = await store.getResource(home); + const apps = (drive.get(APPS) as string[] | undefined) ?? []; + await drive.set( + APPS, + apps.filter(subject => subject !== agent), + ); + await drive.save(); +} diff --git a/browser/data-browser/src/routes/ConnectAgentRoute.tsx b/browser/data-browser/src/routes/ConnectAgentRoute.tsx new file mode 100644 index 0000000000..8b59bc31e4 --- /dev/null +++ b/browser/data-browser/src/routes/ConnectAgentRoute.tsx @@ -0,0 +1,257 @@ +import { useId, useState } from 'react'; +import { createRoute } from '@tanstack/react-router'; +import { + agentSubjectFromPublicKey, + grantAgent, + useResource, + useStore, +} from '@tomic/react'; +import { styled } from 'styled-components'; +import { Button } from '../components/Button'; +import { Card, Margin } from '../components/Card'; +import { ContainerNarrow } from '../components/Containers'; +import { ErrorLook } from '../components/ErrorLook'; +import { Main } from '../components/Main'; +import { Column, Row } from '../components/Row'; +import { Checkbox } from '../components/forms/Checkbox'; +import { RadioInput } from '../components/forms/RadioInput'; +import { useSettings } from '../helpers/AppSettings'; +import { rememberConnectedApp } from '../helpers/connectedApps'; +import { useAccountDriveCatalog } from '../hooks/useAccountDriveCatalog'; +import { usePrivateDrive } from '../hooks/usePrivateDrive'; +import { useSavedDrives } from '../hooks/useSavedDrives'; +import { useNavigateWithTransition } from '../hooks/useNavigateWithTransition'; +import { pathNames, paths } from './paths'; +import { appRoute } from './RootRoutes'; + +export interface ConnectAgentSearch { + key: string; + name: string; +} + +/** + * /app/connect-agent?key=&name=
(body: (args: A, access: Access) => Promise) => + run(async (args: A) => body(args, await access())); + const mcp = new McpServer( { name: 'atomic', version: '0.41.0' }, { - instructions: `Tools for reading and editing Atomic Data (a graph of resources, each with a subject such as did:ad:… and properties). Results use compact JSON-AD: property shortnames as keys, "@id", "@class" and "@parent" as structural keys, and short refs like #AbCd1234 for subjects, which every tool accepts back. The default drive is ${shortenSubject(drive)}. Start with list_drives or search, read resources with get_resource, and use get_user_classes / get_schema before creating resources of a custom class.`, + instructions: `Tools for reading and editing Atomic Data (a graph of resources, each with a subject such as did:ad:… and properties). Results use compact JSON-AD: property shortnames as keys, "@id", "@class" and "@parent" as structural keys, and short refs like #AbCd1234 for subjects, which every tool accepts back. Start with list_drives or search, read resources with get_resource, and use get_user_classes / get_schema before creating resources of a custom class.`, }, ); @@ -91,13 +107,13 @@ export function createAtomicMcpServer({ { title: 'List drives', description: - 'List the drives (top-level workspaces) the signed-in agent has, and which one is the default for the other tools.', + 'List the drives and folders this connection may reach, and which one is the default for the other tools.', inputSchema: {}, annotations: { readOnlyHint: true }, }, - run(async () => { + withAccess(async (_args: Record, { drive, targets }) => { const agent = store.getAgent(); - const subjects = new Set([drive]); + const subjects = new Set(targets); if (agent?.subject) { const agentResource = await store.getResource(agent.subject); @@ -141,7 +157,7 @@ export function createAtomicMcpServer({ }, annotations: { readOnlyHint: true }, }, - run(async ({ subjects, includeCommitData }) => { + withAccess(async ({ subjects, includeCommitData }) => { const result: Record = {}; for (const subjectOrRef of subjects) { @@ -182,7 +198,7 @@ export function createAtomicMcpServer({ }, annotations: { readOnlyHint: true }, }, - run(({ query, parents, limit }) => + withAccess(({ query, parents, limit }, { drive }) => textSearch(store, query, { parents: parents ?? [drive], limit }), ), ); @@ -204,7 +220,7 @@ export function createAtomicMcpServer({ }, annotations: { readOnlyHint: true }, }, - run(({ query, text_query, parents, limit }) => + withAccess(({ query, text_query, parents, limit }, { drive }) => semanticSearch(store, query, { parents: parents ?? [drive], limit, @@ -240,7 +256,7 @@ export function createAtomicMcpServer({ }, annotations: { readOnlyHint: true }, }, - run(args => + withAccess((args, { drive }) => queryResources(store, drive, { ...args, where: args.where.map(({ property, value }) => ({ @@ -260,7 +276,9 @@ export function createAtomicMcpServer({ inputSchema: {}, annotations: { readOnlyHint: true }, }, - run(() => listDriveClasses(store, drive)), + withAccess((_args: Record, { drive }) => + listDriveClasses(store, drive), + ), ); mcp.registerTool( @@ -274,7 +292,7 @@ export function createAtomicMcpServer({ }, annotations: { readOnlyHint: true }, }, - run(({ subject }) => toClassObject(expandSubject(subject), store)), + withAccess(({ subject }) => toClassObject(expandSubject(subject), store)), ); if (!allowWrites) { @@ -299,7 +317,7 @@ export function createAtomicMcpServer({ }, annotations: { readOnlyHint: false, destructiveHint: false }, }, - run(({ subject, property, value }) => + withAccess(({ subject, property, value }) => setResourceProperty(store, subject, property, value), ), ); @@ -315,7 +333,7 @@ export function createAtomicMcpServer({ }, annotations: { readOnlyHint: false, destructiveHint: false }, }, - run(async ({ resources }) => { + withAccess(async ({ resources }, { drive }) => { const created: string[] = []; const errors: string[] = []; const resolved: Record = {}; @@ -355,7 +373,7 @@ export function createAtomicMcpServer({ inputSchema: { subject: z.string() }, annotations: { readOnlyHint: false, destructiveHint: true }, }, - run(async ({ subject }) => { + withAccess(async ({ subject }) => { const resource = await store.getResource(expandSubject(subject)); if (resource.error) { diff --git a/planning/mcp-endpoint.md b/planning/mcp-endpoint.md index 310af34278..a6310cfb31 100644 --- a/planning/mcp-endpoint.md +++ b/planning/mcp-endpoint.md @@ -4,7 +4,11 @@ > data verbs live in `@tomic/lib` (`assistant-tools.ts`, with > `json-ad-compact.ts`, `subject-refs.ts`, `class-schema.ts`), the in-app > assistant calls them, and `browser/mcp` (`@tomic/mcp`) is the local stdio -> server. Steps 3 to 5 (hosted, OAuth) are not started. +> server. It signs with its own key, which the person approves in the app +> (`/app/connect-agent`) for chosen drives, read or read and edit, and can +> revoke under Connected apps; the grant is the ACL on those drives, since +> only an agent may edit its own Agent resource. Steps 3 to 5 (hosted, +> OAuth) are not started. > Companion to [`actions.md`](./actions.md) (one verb list, many surfaces), > [`json-ad-compact.md`](./json-ad-compact.md) (the wire dialect), > [`atomic-lib-runtime.md`](./atomic-lib-runtime.md) (`AtomicNode`), @@ -152,8 +156,9 @@ module. Long-term the graph verbs belong on `AtomicNode`; UI verbs do not. working; this is the shared library MCP will call. Cheap, unblocks everything, and is the [`actions.md`](./actions.md) "fifth enumeration" fix even if MCP slips. -2. [x] **Local stdio MCP.** Node script or small binary. Agent secret from - env / config (same family as `/app/token`). Read + write. This is the +2. [x] **Local stdio MCP.** Node script or small binary. Its own key, + approved in the app (the agent secret still works for scripts). Read + + write. This is the Cursor/Claude Desktop config people actually add. Highest ROI; no AS. 3. [ ] **Server `format=compact`.** json-ad-compact phase 4. Needed before a Rust remote handler is worth writing. From 50a8646657f081d190adaaef553a2466954f5267 Mon Sep 17 00:00:00 2001 From: Joep Meindertsma Date: Mon, 28 Sep 2026 17:37:36 +0000 Subject: [PATCH 4/7] Make connecting an app with its own key a general lib API Any app that makes its own key (a CLI, a script, the MCP server) can now ask for access the same way: connectAgentUrl builds the approval link, optionally asking for edit rights or specific resources, waitForGrant polls until the person clicks Allow, and publishAgentName sets the name shown under Connected apps. The approval page honours the write and target hints as preselections; the person still decides. The MCP server now uses these instead of its own copies. --- browser/CHANGELOG.md | 17 +-- .../src/routes/ConnectAgentRoute.tsx | 37 ++++-- browser/lib/src/agent-grants.test.ts | 46 +++++++- browser/lib/src/agent-grants.ts | 106 ++++++++++++++++++ browser/mcp/src/index.ts | 37 +++--- browser/mcp/src/local-agent.ts | 58 +--------- 6 files changed, 211 insertions(+), 90 deletions(-) diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index 400c8c9464..68f284f550 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -10,7 +10,10 @@ This changelog covers all five packages, as they are (for now) updated as a whol (`/app/connect-agent`) where you pick the drives it may reach and whether it may edit, so your own secret is never shared. Account settings lists connected apps under **Connected apps**, with Revoke. The grants are the ACLs - themselves (`grantAgent`, `grantsTo`, `revokeAgent` in `@tomic/lib`). + themselves (`grantAgent`, `grantsTo`, `revokeAgent` in `@tomic/lib`). Any + app with its own key can use the same page: `connectAgentUrl` builds the link + (optionally asking for edit rights or specific resources), `waitForGrant` + waits for Allow, and `publishAgentName` sets the name the person sees. `@tomic/lib` now also exports the data verbs behind it and behind the in-app assistant (`readResourceCompact`, `queryResources`, `textSearch`, `semanticSearch`, `listDriveClasses`, @@ -250,7 +253,7 @@ This changelog covers all five packages, as they are (for now) updated as a whol fresh one. - The data-browser no longer reports to Sentry from a dev server. Vite's hot - reload legitimately throws while swapping modules ("_s is not a function", + reload legitimately throws while swapping modules ("\_s is not a function", "Cannot access X before initialization", all with `@react-refresh` frames), which is not a defect in anything shipped, and those arrived rated above every real bug in the backlog. `initSentry` returns early when the resolved @@ -422,10 +425,10 @@ This changelog covers all five packages, as they are (for now) updated as a whol - **One-tap create.** A button above the rows that makes one: type "Milk" and press, or press once and the moment is recorded. Configure it from a view's tab menu — a label, optionally a field to type into, and optionally one value the new row starts with, which reuses the same four verbs the row actions use (so "Log set" can stamp today's date and create in a single press). With no field it is the whole interaction; with one it clears after each press, and Enter works, so a shopping list can be filled at speed. Stored on the View as `view-quick-add`, so two views of a table can offer different buttons, and `create_table` / `configure_view` take `quickAdd`. Shipped on the Grocery list ("Add item"), Plant care ("Add plant") and Workout log ("Log set"). - **Row actions: a button on every row, as configuration rather than code.** The verb a mini-app is mostly made of — "Watered", "Mark done", "+1", "Got it". Four kinds, and deliberately only four: stamp the current time into a date column, write one fixed value, flip a checkbox, or add a fixed amount to a number (use −1 for a "one fewer" button). Add one from **Add column → Action**, edit or remove it from its column heading. A button that records state reads as engaged once it has, so it doubles as the readout, and it goes busy then confirms while the commit is in flight. Every press is an ordinary commit on that row: rights-checked, synced, in history, undoable — and no buttons render at all for someone who cannot write, since an action that is going to be rejected is worse than none. Stored on the View as `view-row-actions`, so two views of a table can offer different buttons. The Plant care template now ships **Watered**, Inventory **+1 / −1**, and the Grocery list **Got it**. `create_table` and `configure_view` take `rowActions`, and `describe_table` reads them back. - Fix: a computed column now updates as soon as the column it derives from changes. It used to show whatever it computed when it was first drawn — edit the quantity and its "Quantity × Unit price" stayed put until you reloaded the page; press a row action that stamps a date and the "days since" beside it did not move. The React Compiler was caching the computation against a resource identity that never changes, because the store mutates resources in place. Computed columns are now functions of the values they read, and each cell subscribes to exactly those. The timer's Start/Stop button had the same latent problem and is fixed with it. (A computed cell is still blank on the trailing row you are typing into, until that row is saved and redrawn.) -- Fix: the Grocery list template could not be created at all. Its "Meat & fish" aisle option became the shortname `meat--fish`, which the slug rule rejects — `stringToSlug` stripped the `&` only *after* collapsing repeated dashes, leaving the two dashes it had been sitting between. Any name with punctuation between words hit this. -- Fix: a dash could not be typed into a shortname field. The slug rule above is a *final* form — it drops leading and trailing dashes — and the input applied it to every keystroke, so the `-` you were typing was trailing for exactly as long as it took to press the next key: "is-valid" came out as "isvalid". Hyphenated shortnames were untypeable, in the ontology editor and every New Resource form. The field now keeps a single trailing dash while you type and settles it on blur, validating the settled value so no "Invalid Slug" flashes between the words of a name. +- Fix: the Grocery list template could not be created at all. Its "Meat & fish" aisle option became the shortname `meat--fish`, which the slug rule rejects — `stringToSlug` stripped the `&` only _after_ collapsing repeated dashes, leaving the two dashes it had been sitting between. Any name with punctuation between words hit this. +- Fix: a dash could not be typed into a shortname field. The slug rule above is a _final_ form — it drops leading and trailing dashes — and the input applied it to every keystroke, so the `-` you were typing was trailing for exactly as long as it took to press the next key: "is-valid" came out as "isvalid". Hyphenated shortnames were untypeable, in the ontology editor and every New Resource form. The field now keeps a single trailing dash while you type and settles it on blur, validating the settled value so no "Invalid Slug" flashes between the words of a name. -- **Dashboards.** A new kind of page that composes blocks over your data: a number, a chart, an embedded table, a note — arranged on a grid. Four block kinds in v1. A **number** is a sum, count, average, min or max computed by the store over *every* row a view matches, so it is exact regardless of paging, and it can measure a computed column (a total duration, quantity × price) as well as a stored one. A **chart** is the same number per bucket, drawn as horizontal bars, bucketed exactly or per day or month. A **table** block embeds the real table — cells stay editable, columns sortable — rather than a snapshot. A **note** is markdown. Blocks are resources of their own, so the same dashboard can be built by hand and by the assistant: `create_dashboard` builds one in a call (resolving column and view names, laying blocks out automatically), `describe_dashboard` reads it back, and `configure_block` changes one field without disturbing the rest — everything the tools can write, the per-block Configure dialog can change. A stat or chart block points at one of the table's *views* and borrows its filters, so "open issues" is the open-issues view plus a count instead of a filter restated in two places. Create one from the New menu. +- **Dashboards.** A new kind of page that composes blocks over your data: a number, a chart, an embedded table, a note — arranged on a grid. Four block kinds in v1. A **number** is a sum, count, average, min or max computed by the store over _every_ row a view matches, so it is exact regardless of paging, and it can measure a computed column (a total duration, quantity × price) as well as a stored one. A **chart** is the same number per bucket, drawn as horizontal bars, bucketed exactly or per day or month. A **table** block embeds the real table — cells stay editable, columns sortable — rather than a snapshot. A **note** is markdown. Blocks are resources of their own, so the same dashboard can be built by hand and by the assistant: `create_dashboard` builds one in a call (resolving column and view names, laying blocks out automatically), `describe_dashboard` reads it back, and `configure_block` changes one field without disturbing the rest — everything the tools can write, the per-block Configure dialog can change. A stat or chart block points at one of the table's _views_ and borrows its filters, so "open issues" is the open-issues view plus a count instead of a filter restated in two places. Create one from the New menu. - Fix: a filtered view kept a row whose value had stopped matching it. Raise a "Quantity at most 3" row to 40 and it stayed in the Low stock view — across a reload, because the local database's index still listed it as a member. Editing a row out of a filtered view now removes it, editing one into a filter still adds it in the right sorted position, and an edit that keeps a row in the view but changes what it sorts by no longer lists it twice. @@ -434,7 +437,7 @@ This changelog covers all five packages, as they are (for now) updated as a whol - [#1238](https://github.com/ontola/atomic-server/issues/1238) **Eleven new table templates**, and not one of them ships a renderer: Expenses, Deals (CRM), Job applications, Project tasks, Reading list, Grocery list, Workout log, Plant care, Inventory, Guest list and Bookmarks, next to Issue Tracker and Time tracker. Each arrives configured — kanban and calendar layouts, computed columns (days since you last contacted a deal, when a plant is next due, quantity × price), totals broken down per month or category, its own column order, and a filtered view like Inventory's "Low stock". They are available to the assistant through `list_table_templates` / `create_table_from_template`, which now also reports what each view already computes, so it can start from the closest one and adapt it rather than deriving a schema from scratch. The New Table dialog gives each an icon and scrolls, instead of pushing the name field off the dialog. - Columns can be `decimal`, not just whole `number`: money, prices, hours and measurements keep their cents. It's a float carrying the FormattedNumber shape the property form writes, so switching one to a currency or a different precision afterwards works as usual. `create_table` takes it too — an amount asked for as `number` would have silently rounded. - [#1236](https://github.com/ontola/atomic-server/issues/1236) Timer view for tables, alongside table / kanban / calendar. It **is** the table: the same grid, so cells stay editable, columns sortable, resizable and reorderable, keyboard navigation works and long histories stay virtualised. On top of it the timer adds a toolbar — name a thing and hit Start — and two columns: a live-ticking Duration and a Start/Stop button. "One at a time" (on by default) stops the running entry when another starts; turn it off to track several things at once. Its start property lives in the View's `view-group-by` slot, its end in the new `view-end-prop`, and the toggle in `view-timer-exclusive`; a class without timestamp properties gets "Start" and "End" created for it. -- [#1238](https://github.com/ontola/atomic-server/issues/1238) **The assistant can now change a table, not just create one.** Five tools: `describe_table` reads back the row class, every column and every view's settings (sort, filters, visible columns, computed columns, totals); `configure_view` changes a view in place and touches only the fields it's given, so setting a sort can't drop the filters; `add_table_columns` adds columns to an existing class *and* appends them to the views that keep an explicit column list (a column missing from that list is hidden, which is why adding a property alone wasn't enough); and `list_table_templates` / `create_table_from_template` start from the catalogue instead of re-deriving a schema. `create_table` grew to match: per-view `sortByColumn` / `sortDesc` / `filters` / `columns` / `columnOrder`, and `relation` columns can name their target class so the cell picks from that class instead of searching everything. Column names are resolved case-insensitively by name, shortname or subject throughout, and a select filter accepts an option's name. The whole chain is covered end-to-end by an e2e test that scripts the model and lets the tools run for real. +- [#1238](https://github.com/ontola/atomic-server/issues/1238) **The assistant can now change a table, not just create one.** Five tools: `describe_table` reads back the row class, every column and every view's settings (sort, filters, visible columns, computed columns, totals); `configure_view` changes a view in place and touches only the fields it's given, so setting a sort can't drop the filters; `add_table_columns` adds columns to an existing class _and_ appends them to the views that keep an explicit column list (a column missing from that list is hidden, which is why adding a property alone wasn't enough); and `list_table_templates` / `create_table_from_template` start from the catalogue instead of re-deriving a schema. `create_table` grew to match: per-view `sortByColumn` / `sortDesc` / `filters` / `columns` / `columnOrder`, and `relation` columns can name their target class so the cell picks from that class instead of searching everything. Column names are resolved case-insensitively by name, shortname or subject throughout, and a select filter accepts an option's name. The whole chain is covered end-to-end by an e2e test that scripts the model and lets the tools run for real. - **Column order is per-view configuration now**, and covers every kind of column. Drag any heading — including a computed column or the timer's Start/Stop — and the order is saved on the View (`view-column-order`, a list of column keys), so two views of the same table can arrange the same columns differently. A column added after the order was saved appears at the end rather than disappearing, and a LocalizedText property split per language still moves as one column. The timer view now leads with its Duration and Start/Stop: timing something is the point of that view, so its controls sit where the eye starts instead of past four columns of data. - Fix: columns a view adds (a computed column, the timer's Start/Stop) could not be resized, and rendered at the 300px default rather than the width they asked for. Two separate bugs: the table re-imposed their default width on every render, throwing away the resize it had just stored; and the grid's size list, when the column count and the stored widths changed in the same commit, appended defaults over the widths using a stale copy. Their heading also matched the header cell's bold instead of a property heading's regular weight, and the grid's 100px minimum column width made dragging a 70px button column snap wider instead of resizing it — the floor is now 40px. Default widths themselves are unchanged: narrow is right for a duration or a button. - Fix: switching between views whose column counts differ painted the previous view's column widths for a frame, which churned the grid while it settled. The width list is now ignored when it doesn't match the columns on screen, rather than rendered. @@ -443,7 +446,7 @@ This changelog covers all five packages, as they are (for now) updated as a whol - **More than one totals row.** Add rows from the totals menu and give a column a different statistic in each, so Amount can show a sum on one line and an average on the next. Stored per aggregate as a `row` index; `configure_view` takes it too. A row you add holds nothing until you fill a cell in it, so an empty one is never persisted. - **Totals now live in a footer row under the grid**, each under the column it describes — click a column's footer cell and pick Sum / Average / Min / Max / Count, the way a spreadsheet does. The row is always in view (the rows scroll inside their own box) and scrolls sideways with the columns, so a total stays under its column. Its left cell shows how many rows the view matches and carries the "break down by…" menu. This replaces the Σ button in the top-right corner and the free-floating totals strip: a number is much easier to read directly beneath its column than in a labelled list somewhere else. - Fix: totals didn't update when the data changed — they came from the row collection, which patches its pages surgically on an edit rather than re-querying. They now ride a small query of their own (one row plus the numbers), re-read on save/delete with a short debounce. That also means refreshing them no longer clears the grid's pages under the cursor. -- [#1238](https://github.com/ontola/atomic-server/issues/1238) **Totals under the table**, computed by the server: a sum, count, average, min or max over **every** row a view matches — filters included, paging excluded — plus an optional breakdown giving one subtotal per distinct value of a column (per project, per status, per day, per month). This is a new query capability rather than a client-side add-up: `Query` carries an `aggregation`, the store walks its own index and returns a handful of numbers on the Collection's new `collection/aggregates` property, and because the browser's local database runs the same Rust code, an offline table shows the same totals. Configure it from the Σ button next to the table's filters, or ask `create_table` for `aggregates` / `breakdownColumn`. Day and month buckets use your timezone, not UTC. Stored on the View as `view-aggregates` / `view-group-by-column` / `view-group-granularity`. Note that a `count` counts the rows you can actually read, which can be lower than `totalMembers` (that one counts raw index hits). Aggregating a *derived* column isn't possible yet — those aren't stored. +- [#1238](https://github.com/ontola/atomic-server/issues/1238) **Totals under the table**, computed by the server: a sum, count, average, min or max over **every** row a view matches — filters included, paging excluded — plus an optional breakdown giving one subtotal per distinct value of a column (per project, per status, per day, per month). This is a new query capability rather than a client-side add-up: `Query` carries an `aggregation`, the store walks its own index and returns a handful of numbers on the Collection's new `collection/aggregates` property, and because the browser's local database runs the same Rust code, an offline table shows the same totals. Configure it from the Σ button next to the table's filters, or ask `create_table` for `aggregates` / `breakdownColumn`. Day and month buckets use your timezone, not UTC. Stored on the View as `view-aggregates` / `view-group-by-column` / `view-group-granularity`. Note that a `count` counts the rows you can actually read, which can be lower than `totalMembers` (that one counts raw index hits). Aggregating a _derived_ column isn't possible yet — those aren't stored. - [#1238](https://github.com/ontola/atomic-server/issues/1238) **Derived columns**: a table view can show columns computed from each row rather than stored on it — a live duration, a days-since, an amount, a next-due date. They are configuration, not code: the View lists them in the new `view-derived-columns` (JSON), any view kind renders them, and `create_table` can build them, so a mini-app that needs one is a template rather than a renderer. Five fixed generators, deliberately not a formula language: `difference` (to − from), `elapsed` (the ticking variant, stopping once its end is stamped), `daysSince`, `product` (either factor may be a literal, e.g. a rate) and `offset` (a date plus a number of days). The timer's Duration is now one of them — an `elapsed` over the two timestamps the view already knew about, seeded by the Time tracker template — so it renders in a plain table view too, and the timer keeps only its Start/Stop column. Add one from **Add column → Computed**: the dialog is generated from the generator itself, so it only offers columns that fit each argument (date columns for a duration, number columns for a multiplication) and lets you type a fixed number where one makes sense. Its heading carries the menu that edits or removes it again. See `planning/table-templates-and-mini-apps.md`. - Table columns may now be **virtual** — rendered by the view rather than read off a Property (`TableColumn.virtual`). The grid stack was already generic over its column type, so this cost the editor nothing, and it's the seam that future derived columns and row actions plug into. See `planning/table-templates-and-mini-apps.md`. - The table's filter and property menus are both plain dropdowns now, instead of one dropdown and one popover full of checkboxes. Each has a header saying what it does, whole rows are the click target rather than a small checkbox, shown properties are marked with a check, and both label properties by their human title. Toggling a property leaves the menu open — `DropdownMenu` items take a new `keepOpen` for menus that toggle state rather than navigate away. Properties a view is structurally built on (a timer's name/start/end, a kanban's or calendar's group-by) are shown locked with a reason instead of offering a toggle that the view would ignore. diff --git a/browser/data-browser/src/routes/ConnectAgentRoute.tsx b/browser/data-browser/src/routes/ConnectAgentRoute.tsx index 8b59bc31e4..d044c8905a 100644 --- a/browser/data-browser/src/routes/ConnectAgentRoute.tsx +++ b/browser/data-browser/src/routes/ConnectAgentRoute.tsx @@ -27,13 +27,17 @@ import { appRoute } from './RootRoutes'; export interface ConnectAgentSearch { key: string; name: string; + /** Hints from the app (see `connectAgentUrl`): the person still decides. */ + write?: boolean; + target?: string[]; } /** - * /app/connect-agent?key=&name=