feat(memory): expose knowledge graph as MCP Resource (#3323)

* feat(memory): expose knowledge graph as MCP Resource

Register the knowledge graph as a readable MCP Resource at
memory://knowledge-graph, enabling clients to discover and read
the graph via the Resources protocol without invoking a tool.

Mutation tools (create_entities, create_relations, add_observations,
delete_entities, delete_observations, delete_relations) now emit
notifications/resources/updated so subscribed clients see live
graph changes.

Resource registration is extracted into the exported
registerKnowledgeGraphResource() function so it can be unit-tested
in isolation, mirroring the everything server's registration
pattern. New tests assert registerResource is called with the
correct kebab-case name, URI, mime type, and that the handler
returns the graph as JSON in the contents array.

Addresses PR #3323 review feedback:
- kebab-case resource name per CLAUDE.md style guide
- single-line comment per CLAUDE.md style guide
- title metadata for client discoverability
- resource update notifications after mutations
- test coverage matching everything/registrations.test.ts pattern
- README documents the new Resource

* feat(memory): support resources/subscribe for the knowledge-graph resource

PR #3323 emitted notifications/resources/updated after every mutation but
the server only advertised resources.listChanged, so resources/subscribe
returned -32601 and a spec-compliant client could never actually subscribe.

Declare resources.subscribe, add subscribe/unsubscribe request handlers that
track subscribed URIs, and only emit notifications/resources/updated when the
client has subscribed to that URI.

Co-authored-by: Lazreg Raed <lazregraed222@gmail.com>

---------

Co-authored-by: olaservo <olahungerford@gmail.com>
This commit is contained in:
Lazreg Raed 2026-06-17 03:40:51 +02:00 • committed by GitHub
commit 7b1170d1da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -0,0 +1,97 @@
import { describe, it, expect, vi } from 'vitest';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SubscribeRequestSchema, UnsubscribeRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import {
KnowledgeGraphManager,
registerKnowledgeGraphResource,
registerKnowledgeGraphSubscriptions,
} from '../index.js';
describe('knowledge-graph resource', () => {
it('registers with kebab-case name, correct URI, and JSON mime type', () => {
const mockServer = { registerResource: vi.fn() } as unknown as McpServer;
const manager = {} as KnowledgeGraphManager;
registerKnowledgeGraphResource(mockServer, manager);
expect(mockServer.registerResource).toHaveBeenCalledWith(
'knowledge-graph',
'memory://knowledge-graph',
expect.objectContaining({
title: 'Knowledge Graph',
mimeType: 'application/json',
}),
expect.any(Function),
);
});
it('handler returns the graph as JSON in the contents array', async () => {
const mockServer = { registerResource: vi.fn() } as unknown as McpServer;
const fakeGraph = {
entities: [{ name: 'Alice', entityType: 'person', observations: ['engineer'] }],
relations: [{ from: 'Alice', to: 'Acme', relationType: 'works_at' }],
};
const manager = {
readGraph: vi.fn().mockResolvedValue(fakeGraph),
} as unknown as KnowledgeGraphManager;
registerKnowledgeGraphResource(mockServer, manager);
const handler = (mockServer.registerResource as ReturnType<typeof vi.fn>).mock.calls[0][3];
const result = await handler(new URL('memory://knowledge-graph'));
expect(result.contents).toHaveLength(1);
expect(result.contents[0].uri).toBe('memory://knowledge-graph');
expect(result.contents[0].mimeType).toBe('application/json');
expect(JSON.parse(result.contents[0].text)).toEqual(fakeGraph);
expect(manager.readGraph).toHaveBeenCalledOnce();
});
});
describe('knowledge-graph resource subscriptions', () => {
function makeMockServer() {
const inner = {
registerCapabilities: vi.fn(),
setRequestHandler: vi.fn(),
sendResourceUpdated: vi.fn(),
};
const mockServer = { server: inner } as unknown as McpServer;
return { mockServer, inner };
}
function handlerFor(inner: ReturnType<typeof makeMockServer>['inner'], schema: unknown) {
const call = inner.setRequestHandler.mock.calls.find((c) => c[0] === schema);
if (!call) throw new Error('handler not registered');
return call[1] as (request: { params: { uri: string } }) => Promise<unknown>;
}
it('declares the resources.subscribe capability', () => {
const { mockServer, inner } = makeMockServer();
registerKnowledgeGraphSubscriptions(mockServer);
expect(inner.registerCapabilities).toHaveBeenCalledWith({
resources: { subscribe: true },
});
});
it('registers subscribe and unsubscribe request handlers', () => {
const { mockServer, inner } = makeMockServer();
registerKnowledgeGraphSubscriptions(mockServer);
const schemas = inner.setRequestHandler.mock.calls.map((c) => c[0]);
expect(schemas).toContain(SubscribeRequestSchema);
expect(schemas).toContain(UnsubscribeRequestSchema);
});
it('subscribe and unsubscribe handlers acknowledge with an empty result', async () => {
const { mockServer, inner } = makeMockServer();
registerKnowledgeGraphSubscriptions(mockServer);
const req = { params: { uri: 'memory://knowledge-graph' } };
await expect(handlerFor(inner, SubscribeRequestSchema)(req)).resolves.toEqual({});
await expect(handlerFor(inner, UnsubscribeRequestSchema)(req)).resolves.toEqual({});
});
});