diff --git a/.changeset/mcp-query-schema-filter.md b/.changeset/mcp-query-schema-filter.md new file mode 100644 index 000000000..043b27806 --- /dev/null +++ b/.changeset/mcp-query-schema-filter.md @@ -0,0 +1,13 @@ +--- +'@xnetjs/plugins': minor +--- + +`xnet_query` and `xnet_create` now honour the `schemaId` argument. Both tools +read `schemaId` first and keep `schema` as a deprecated alias — previously they +read only `schema`, so an MCP client that passed `schemaId` (the field name +every node carries) had its filter dropped: `xnet_query` fell through to an +unfiltered `store.list` and answered "my pages" with nodes of every schema, +while `xnet_create` could mint a node with no schema at all. + +A call that supplies neither spelling now fails with a clear error instead of +widening to every node. diff --git a/packages/plugins/src/__tests__/mcp-server.test.ts b/packages/plugins/src/__tests__/mcp-server.test.ts index 5f94dd499..8aca29f94 100644 --- a/packages/plugins/src/__tests__/mcp-server.test.ts +++ b/packages/plugins/src/__tests__/mcp-server.test.ts @@ -256,6 +256,80 @@ describe('MCPServer', () => { const data = JSON.parse(result.content[0].text) expect(data.nodes).toHaveLength(2) }) + + it('returns only nodes of the requested schema', async () => { + await mockStore.create({ + schemaId: 'xnet://xnet.dev/Task', + properties: { title: 'Task 1' } + }) + await mockStore.create({ + schemaId: 'xnet://xnet.dev/Project', + properties: { name: 'Project 1' } + }) + + const response = await server.handleRequest( + createRequest('tools/call', { + name: 'xnet_query', + arguments: { schema: 'xnet://xnet.dev/Task' } + }) + ) + + const result = response.result as { content: Array<{ type: string; text: string }> } + const data = JSON.parse(result.content[0].text) as { + nodes: Array<{ schemaId: string }> + count: number + } + expect(data.nodes).toHaveLength(1) + expect(data.count).toBe(1) + for (const node of data.nodes) { + expect(node.schemaId).toBe('xnet://xnet.dev/Task') + } + }) + + // Agents reach for `schemaId` because that is the field name on every node + // the tools hand back. Dropping it used to silently return every schema. + it('accepts schemaId as an alias and still filters', async () => { + await mockStore.create({ + schemaId: 'xnet://xnet.dev/Task', + properties: { title: 'Task 1' } + }) + await mockStore.create({ + schemaId: 'xnet://xnet.dev/Project', + properties: { name: 'Project 1' } + }) + + const response = await server.handleRequest( + createRequest('tools/call', { + name: 'xnet_query', + arguments: { schemaId: 'xnet://xnet.dev/Task' } + }) + ) + + expect(response.error).toBeUndefined() + const result = response.result as { content: Array<{ type: string; text: string }> } + const data = JSON.parse(result.content[0].text) as { + nodes: Array<{ schemaId: string }> + } + expect(data.nodes).toHaveLength(1) + expect(data.nodes[0].schemaId).toBe('xnet://xnet.dev/Task') + }) + + it('fails loudly instead of returning every node when no schema is given', async () => { + await mockStore.create({ + schemaId: 'xnet://xnet.dev/Task', + properties: { title: 'Task 1' } + }) + + const response = await server.handleRequest( + createRequest('tools/call', { + name: 'xnet_query', + arguments: { limit: 3 } + }) + ) + + expect(response.result).toBeUndefined() + expect(response.error?.message).toContain('schemaId') + }) }) describe('tools/call - xnet_get', () => { diff --git a/packages/plugins/src/services/mcp-server.ts b/packages/plugins/src/services/mcp-server.ts index f786dd7cd..aa28629e5 100644 --- a/packages/plugins/src/services/mcp-server.ts +++ b/packages/plugins/src/services/mcp-server.ts @@ -395,9 +395,14 @@ export class MCPServer { inputSchema: { type: 'object', properties: { + schemaId: { + type: 'string', + description: + 'Schema IRI to query, exactly as it appears on a node (e.g. xnet://xnet.fyi/Task@1.0.0). Required.' + }, schema: { type: 'string', - description: 'Schema IRI to query (e.g., xnet://xnet.dev/Task)' + description: 'Deprecated alias for schemaId.' }, limit: { type: 'number', @@ -408,7 +413,7 @@ export class MCPServer { description: 'Number of results to skip for pagination' } }, - required: ['schema'] + required: ['schemaId'] } }) @@ -434,9 +439,13 @@ export class MCPServer { inputSchema: { type: 'object', properties: { + schemaId: { + type: 'string', + description: 'Schema IRI for the new node (e.g. xnet://xnet.fyi/Task@1.0.0). Required.' + }, schema: { type: 'string', - description: 'Schema IRI for the new node' + description: 'Deprecated alias for schemaId.' }, properties: { type: 'object', @@ -445,7 +454,7 @@ export class MCPServer { confirm: CONFIRM_SCHEMA, provenance: PROVENANCE_SCHEMA }, - required: ['schema', 'properties'] + required: ['schemaId', 'properties'] } }) @@ -596,7 +605,7 @@ export class MCPServer { switch (name) { case 'xnet_query': { - const schemaId = toolArgs.schema as string + const schemaId = requiredSchemaArg(toolArgs, 'xnet_query') const limit = (toolArgs.limit as number) ?? 20 const offset = (toolArgs.offset as number) ?? 0 @@ -616,7 +625,7 @@ export class MCPServer { } case 'xnet_create': { - const schema = toolArgs.schema as string + const schema = requiredSchemaArg(toolArgs, 'xnet_create') const properties = toolArgs.properties as Record result = await this.guardedWrite( { kind: 'create', schemaId: schema, ...readWriteGate(toolArgs) }, @@ -843,6 +852,23 @@ const PROVENANCE_SCHEMA: MCPPropertySchema = { 'Optional AI provenance for the write: { sourceType: "local-ai"|"cloud-ai", modelProvider, modelName }.' } +/** + * Read the schema IRI off a tool call, accepting either spelling. + * + * Agents reach for `schemaId` — it is the field name on every node the tools + * hand back — while these tools were declared with `schema`. Reading only + * `schema` turned a filtered query into an unfiltered one (`store.list` treats + * an absent `schemaId` as "every schema"), so `xnet_query` silently answered + * "my pages" with canvases. A missing filter must fail, never widen. + */ +function requiredSchemaArg(args: Record, tool: string): string { + const value = args.schemaId ?? args.schema + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${tool} requires a schemaId (schema IRI) argument`) + } + return value +} + /** Read the shared write-gate args (confirm + provenance) off a tool call. */ function readWriteGate(args: Record): { confirm: boolean diff --git a/site/src/data/changelog/2026-07-27-agent-queries-return-only-the-type-you-a.json b/site/src/data/changelog/2026-07-27-agent-queries-return-only-the-type-you-a.json new file mode 100644 index 000000000..9eaacfe08 --- /dev/null +++ b/site/src/data/changelog/2026-07-27-agent-queries-return-only-the-type-you-a.json @@ -0,0 +1,8 @@ +{ + "id": "2026-07-27-agent-queries-return-only-the-type-you-a", + "date": "July 27, 2026", + "title": "Agent queries return only the type you asked for", + "summary": "The xnet_query tool used to ignore a schemaId filter and return nodes of every type, so an agent asked about your pages could answer with canvases. It now filters correctly, and a query with no type at all fails loudly instead of quietly returning everything.", + "highlights": [], + "tags": ["ai"] +}