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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/mcp-query-schema-filter.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 74 additions & 0 deletions packages/plugins/src/__tests__/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
38 changes: 32 additions & 6 deletions packages/plugins/src/services/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -408,7 +413,7 @@ export class MCPServer {
description: 'Number of results to skip for pagination'
}
},
required: ['schema']
required: ['schemaId']
}
})

Expand All @@ -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',
Expand All @@ -445,7 +454,7 @@ export class MCPServer {
confirm: CONFIRM_SCHEMA,
provenance: PROVENANCE_SCHEMA
},
required: ['schema', 'properties']
required: ['schemaId', 'properties']
}
})

Expand Down Expand Up @@ -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

Expand All @@ -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<string, unknown>
result = await this.guardedWrite(
{ kind: 'create', schemaId: schema, ...readWriteGate(toolArgs) },
Expand Down Expand Up @@ -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<string, unknown>, 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<string, unknown>): {
confirm: boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading