From 0d3eabecbe048c5ed3e812711a86cbda813181f4 Mon Sep 17 00:00:00 2001 From: Aditya Datta Date: Tue, 15 Sep 2026 21:21:32 +0530 Subject: [PATCH 1/2] feat(mcp): expose tenant-scoped read-only resources --- docs/mcp-resources.md | 17 +++ .../mcp-endpoint.controller.spec.ts | 44 +++++++ .../src/mcp-server/mcp-endpoint.controller.ts | 64 +++++++++- .../src/mcp-server/resource-registry.spec.ts | 47 ++++++++ .../src/mcp-server/resource-registry.ts | 111 ++++++++++++++++++ .../src/mcp-servers/mcp-servers.service.ts | 30 +++++ 6 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 docs/mcp-resources.md create mode 100644 packages/backend/src/mcp-server/resource-registry.spec.ts create mode 100644 packages/backend/src/mcp-server/resource-registry.ts diff --git a/docs/mcp-resources.md b/docs/mcp-resources.md new file mode 100644 index 00000000..4e0f9248 --- /dev/null +++ b/docs/mcp-resources.md @@ -0,0 +1,17 @@ +# MCP resources + +Per-server MCP endpoints expose assigned read-only content through the native +`resources/list` and `resources/read` methods. The resource surface is scoped +to the same connector assignments as tools, so a client cannot discover another +workspace's connector notes. + +AnythingMCP publishes a composed server-instructions resource and one resource +for each assigned connector's setup instructions. Persisted `McpResource` +records are also exposed at their configured URI. A record may contain local +`text`, `content`, or JSON `data` in `fetchConfig`; remote URL fetches are +intentionally not followed by the MCP endpoint, which prevents a resource from +being used as an SSRF primitive. + +Resource registrations are rebuilt when a client opens a new request. Existing +stateful sessions keep their initial resource snapshot and should reconnect +after connector assignments or resource definitions change. diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts index afbc080e..4207b125 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts @@ -33,6 +33,7 @@ describe('McpEndpointController — tenant isolation', () => { mcpServersService = { findById: jest.fn().mockResolvedValue(SERVER), getConnectorIds: jest.fn().mockResolvedValue([]), + getResourcesForServer: jest.fn().mockResolvedValue([]), getComposedInstructions: jest.fn().mockResolvedValue(''), isUserInOrganization: jest.fn().mockResolvedValue(false), }; @@ -277,3 +278,46 @@ describe('McpEndpointController — structuredContent', () => { expect(result.isError).toBe(true); }); }); + +describe('McpEndpointController — native resources', () => { + it('plans server and connector instruction resources plus persisted content', async () => { + const controller = new McpEndpointController( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + const entries = (controller as any).planResources( + 'srv-1', + [ + { + id: 'conn-1', + name: 'CRM', + instructions: 'Use the CRM connector', + resources: [ + { + uri: 'anythingmcp://crm/enums', + name: 'CRM enums', + description: 'Reference values', + mimeType: 'application/json', + fetchConfig: { data: { status: ['open', 'closed'] } }, + }, + ], + }, + ], + 'Prefer read-only calls', + ); + expect(entries.map((entry: any) => entry.uri)).toEqual([ + 'anythingmcp://server/srv-1/instructions', + 'anythingmcp://connector/conn-1/instructions', + 'anythingmcp://crm/enums', + ]); + await expect(entries[2].read()).resolves.toMatchObject({ + mimeType: 'application/json', + text: expect.stringContaining('open'), + }); + }); +}); diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index 81920df8..e7a4d4d3 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -56,6 +56,7 @@ import { } from './tool-annotations'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; +import { makeResource, registerResources, type RegisteredResource } from './resource-registry'; /** * Backend version, reported as the demo server's version (was a hardcoded 1.0.0). @@ -664,9 +665,10 @@ export class McpEndpointController { } // 2. Get connector IDs and composed instructions for this server - const [connectorIds, instructions] = await Promise.all([ + const [connectorIds, instructions, resourceConnectors] = await Promise.all([ this.mcpServersService.getConnectorIds(serverId), this.mcpServersService.getComposedInstructions(serverId), + this.mcpServersService.getResourcesForServer(serverId), ]); // 3. Filter tools to only those from assigned connectors @@ -690,6 +692,13 @@ export class McpEndpointController { { instructions }, ); + // Resources are read-only and scoped to the same assigned connectors as + // tools. Register generated instruction resources alongside persisted + // static resources so agents can attach setup guidance without invoking a + // synthetic tool. + const resources = this.planResources(serverId, resourceConnectors, instructions); + registerResources(mcpServer, resources, (message) => this.logger.warn(message)); + // Build invocation context for audit logging and tool scoping // OAuth JWTs store email inside user_data, app JWTs have it top-level const invocationContext = { @@ -757,6 +766,59 @@ export class McpEndpointController { await this.serveStateless(req, res, body, () => mcpServer, `server ${serverId}`); } + private planResources( + serverId: string, + connectors: Array<{ + id: string; + name: string; + instructions: string | null; + resources: Array<{ + uri: string; + name: string; + description: string | null; + mimeType: string; + fetchConfig: unknown; + }>; + }>, + instructions?: string, + ): RegisteredResource[] { + const planned: RegisteredResource[] = []; + if (instructions) { + planned.push( + makeResource( + { + uri: `anythingmcp://server/${serverId}/instructions`, + name: 'Server instructions', + description: 'Instructions composed from this MCP server and its assigned connectors.', + mimeType: 'text/markdown', + fetchConfig: {}, + }, + { text: instructions, mimeType: 'text/markdown' }, + ), + ); + } + for (const connector of connectors) { + if (connector.instructions) { + planned.push( + makeResource( + { + uri: `anythingmcp://connector/${connector.id}/instructions`, + name: `${connector.name} setup instructions`, + description: `Setup and usage notes for the ${connector.name} connector.`, + mimeType: 'text/markdown', + fetchConfig: {}, + }, + { text: connector.instructions, mimeType: 'text/markdown' }, + ), + ); + } + for (const resource of connector.resources) { + planned.push(makeResource(resource)); + } + } + return planned; + } + /** * Serves one stateless request through the 2026-07-28 handler. * diff --git a/packages/backend/src/mcp-server/resource-registry.spec.ts b/packages/backend/src/mcp-server/resource-registry.spec.ts new file mode 100644 index 00000000..c8fd83f4 --- /dev/null +++ b/packages/backend/src/mcp-server/resource-registry.spec.ts @@ -0,0 +1,47 @@ +import { + contentFromFetchConfig, + makeResource, + registerResources, +} from './resource-registry'; + +describe('resource-registry', () => { + it('serialises static JSON data and preserves an explicit MIME type', () => { + expect(contentFromFetchConfig({ data: { enum: ['a', 'b'] }, mimeType: 'application/json' })).toEqual({ + text: '{\n "enum": [\n "a",\n "b"\n ]\n}', + mimeType: 'application/json', + }); + }); + + it('fails closed for unsupported remote fetch configurations', async () => { + const resource = makeResource({ + uri: 'https://example.test/private', + name: 'remote', + fetchConfig: { type: 'url', url: 'https://169.254.169.254/' }, + }); + await expect(resource.read()).resolves.toEqual({ + text: '[resource content is not available: only local static content is supported]', + mimeType: 'text/plain', + }); + }); + + it('registers each URI once and returns requested URI metadata', async () => { + const registrations: any[] = []; + const server = { + registerResource: (...args: any[]) => { + registrations.push(args); + return { remove: jest.fn() }; + }, + }; + const warn = jest.fn(); + const resource = makeResource( + { uri: 'anythingmcp://x', name: 'x', fetchConfig: {} }, + { text: 'hello', mimeType: 'text/plain' }, + ); + expect(registerResources(server, [resource, resource], warn)).toBe(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Duplicate MCP resource URI')); + const callback = registrations[0][3]; + await expect(callback({ href: 'anythingmcp://x' })).resolves.toEqual({ + contents: [{ uri: 'anythingmcp://x', mimeType: 'text/plain', text: 'hello' }], + }); + }); +}); diff --git a/packages/backend/src/mcp-server/resource-registry.ts b/packages/backend/src/mcp-server/resource-registry.ts new file mode 100644 index 00000000..f1eb4035 --- /dev/null +++ b/packages/backend/src/mcp-server/resource-registry.ts @@ -0,0 +1,111 @@ +/** + * Read-only MCP resource registration helpers. + * + * Resource definitions are assembled by the endpoint after tenant and server + * membership checks have completed. Fetch configs intentionally support only + * values already stored in the database; accepting arbitrary URLs here would + * turn resources into an SSRF primitive. + */ + +export interface ResourceDefinition { + uri: string; + name: string; + description?: string | null; + mimeType?: string | null; + fetchConfig: unknown; +} + +export interface RegisteredResource extends ResourceDefinition { + read: () => Promise<{ text: string; mimeType: string }>; +} + +function jsonText(value: unknown): string { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +/** Convert a persisted fetch config into bounded, local-only content. */ +export function contentFromFetchConfig(fetchConfig: unknown): { + text: string; + mimeType?: string; +} { + if (fetchConfig && typeof fetchConfig === 'object') { + const config = fetchConfig as Record; + if (typeof config.text === 'string') { + return { text: config.text, mimeType: typeof config.mimeType === 'string' ? config.mimeType : undefined }; + } + if (typeof config.content === 'string') { + return { text: config.content, mimeType: typeof config.mimeType === 'string' ? config.mimeType : undefined }; + } + if ('data' in config) { + return { text: jsonText(config.data), mimeType: typeof config.mimeType === 'string' ? config.mimeType : undefined }; + } + } + return { + text: '[resource content is not available: only local static content is supported]', + }; +} + +export function makeResource( + definition: ResourceDefinition, + staticContent?: { text: string; mimeType?: string }, +): RegisteredResource { + const fallback = staticContent ?? contentFromFetchConfig(definition.fetchConfig); + return { + ...definition, + read: async () => ({ + text: fallback.text, + mimeType: fallback.mimeType ?? definition.mimeType ?? 'text/plain', + }), + }; +} + +/** Register resources on one per-request MCP server, deduplicating URIs. */ +export function registerResources( + server: any, + resources: RegisteredResource[], + warn: (message: string) => void = () => undefined, +): number { + const seen = new Set(); + let registered = 0; + for (const resource of resources) { + if (!resource.uri || seen.has(resource.uri)) { + if (resource.uri) warn(`Duplicate MCP resource URI "${resource.uri}" — skipping the extra copy`); + continue; + } + seen.add(resource.uri); + const register = server?.registerResource; + if (typeof register !== 'function') { + warn('MCP SDK does not expose registerResource; skipping resource registration'); + break; + } + register.call( + server, + resource.name, + resource.uri, + { + title: resource.name, + description: resource.description ?? undefined, + mimeType: resource.mimeType ?? undefined, + }, + async (requestedUri: { href?: string }) => { + const content = await resource.read(); + return { + contents: [ + { + uri: requestedUri?.href ?? resource.uri, + mimeType: content.mimeType, + text: content.text, + }, + ], + }; + }, + ); + registered++; + } + return registered; +} diff --git a/packages/backend/src/mcp-servers/mcp-servers.service.ts b/packages/backend/src/mcp-servers/mcp-servers.service.ts index 8365f458..b918594c 100644 --- a/packages/backend/src/mcp-servers/mcp-servers.service.ts +++ b/packages/backend/src/mcp-servers/mcp-servers.service.ts @@ -193,6 +193,36 @@ export class McpServersService { return rows.map((r) => r.connectorId); } + /** + * Read-only content assigned to a server. This deliberately returns only + * connector metadata and persisted resources reachable through the server's + * connector assignments; callers still perform principal checks first. + */ + async getResourcesForServer(serverId: string) { + const rows = await this.prisma.mcpServerConnector.findMany({ + where: { mcpServerId: serverId }, + select: { + connector: { + select: { + id: true, + name: true, + instructions: true, + resources: { + select: { + uri: true, + name: true, + description: true, + mimeType: true, + fetchConfig: true, + }, + }, + }, + }, + }, + }); + return rows.map((row) => row.connector); + } + /** * Compose MCP server instructions from the server's own instructions * plus all assigned connectors' instructions. From 66f35d7f9405e53abc07e9f6cdf249738d816c71 Mon Sep 17 00:00:00 2001 From: Aditya Datta Date: Thu, 17 Sep 2026 08:18:00 +0530 Subject: [PATCH 2/2] fix(mcp): enforce resource role boundaries Signed-off-by: Aditya Datta --- .../mcp-endpoint.controller.spec.ts | 64 ++++++++++++++++++- .../src/mcp-server/mcp-endpoint.controller.ts | 53 +++++++++++---- .../src/mcp-server/resource-registry.spec.ts | 23 +++++-- .../src/mcp-server/resource-registry.ts | 26 ++------ 4 files changed, 127 insertions(+), 39 deletions(-) diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts index 4207b125..43b5abc7 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts @@ -303,7 +303,7 @@ describe('McpEndpointController — native resources', () => { name: 'CRM enums', description: 'Reference values', mimeType: 'application/json', - fetchConfig: { data: { status: ['open', 'closed'] } }, + fetchConfig: { content: '{"status":["open","closed"]}' }, }, ], }, @@ -320,4 +320,66 @@ describe('McpEndpointController — native resources', () => { text: expect.stringContaining('open'), }); }); + + it('omits resources for a connector whose tools are denied by the caller role', () => { + const controller = new McpEndpointController( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + const connectors = [ + { + id: 'conn-allowed', + name: 'Allowed CRM', + instructions: 'Allowed instructions', + resources: [ + { + uri: 'anythingmcp://allowed/reference', + name: 'Allowed reference', + description: null, + mimeType: 'text/plain', + fetchConfig: { text: 'allowed' }, + }, + ], + }, + { + id: 'conn-denied', + name: 'Denied CRM', + instructions: 'Denied instructions', + resources: [ + { + uri: 'anythingmcp://denied/reference', + name: 'Denied reference', + description: null, + mimeType: 'text/plain', + fetchConfig: { text: 'denied' }, + }, + ], + }, + ]; + const tools = [ + { id: 'tool-allowed', connectorId: 'conn-allowed' }, + { id: 'tool-denied', connectorId: 'conn-denied' }, + ]; + + const entries = (controller as any).planRoleScopedResources( + 'srv-1', + connectors, + undefined, + tools, + ['tool-allowed'], + ); + + expect(entries.map((entry: any) => entry.uri)).toEqual([ + 'anythingmcp://connector/conn-allowed/instructions', + 'anythingmcp://allowed/reference', + ]); + expect(entries.map((entry: any) => entry.uri)).not.toContain( + 'anythingmcp://denied/reference', + ); + }); }); diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index e7a4d4d3..cf2891b8 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -124,6 +124,19 @@ interface ToolSetParams { resultFooter?: string; } +interface ResourceConnector { + id: string; + name: string; + instructions: string | null; + resources: Array<{ + uri: string; + name: string; + description: string | null; + mimeType: string; + fetchConfig: unknown; + }>; +} + interface InvocationContext { userId?: string; userEmail?: string; @@ -696,7 +709,13 @@ export class McpEndpointController { // tools. Register generated instruction resources alongside persisted // static resources so agents can attach setup guidance without invoking a // synthetic tool. - const resources = this.planResources(serverId, resourceConnectors, instructions); + const resources = this.planRoleScopedResources( + serverId, + resourceConnectors, + instructions, + serverTools, + allowedToolIds, + ); registerResources(mcpServer, resources, (message) => this.logger.warn(message)); // Build invocation context for audit logging and tool scoping @@ -768,18 +787,7 @@ export class McpEndpointController { private planResources( serverId: string, - connectors: Array<{ - id: string; - name: string; - instructions: string | null; - resources: Array<{ - uri: string; - name: string; - description: string | null; - mimeType: string; - fetchConfig: unknown; - }>; - }>, + connectors: ResourceConnector[], instructions?: string, ): RegisteredResource[] { const planned: RegisteredResource[] = []; @@ -819,6 +827,25 @@ export class McpEndpointController { return planned; } + private planRoleScopedResources( + serverId: string, + connectors: ResourceConnector[], + instructions: string | undefined, + serverTools: RegisteredTool[], + allowedToolIds: string[] | null, + ): RegisteredResource[] { + const allowedConnectorIds = new Set( + serverTools + .filter((tool) => allowedToolIds === null || allowedToolIds.includes(tool.id)) + .map((tool) => tool.connectorId), + ); + return this.planResources( + serverId, + connectors.filter((connector) => allowedConnectorIds.has(connector.id)), + instructions, + ); + } + /** * Serves one stateless request through the 2026-07-28 handler. * diff --git a/packages/backend/src/mcp-server/resource-registry.spec.ts b/packages/backend/src/mcp-server/resource-registry.spec.ts index c8fd83f4..a31d0f6d 100644 --- a/packages/backend/src/mcp-server/resource-registry.spec.ts +++ b/packages/backend/src/mcp-server/resource-registry.spec.ts @@ -3,12 +3,27 @@ import { makeResource, registerResources, } from './resource-registry'; +import type { McpServer } from '@modelcontextprotocol/server'; describe('resource-registry', () => { - it('serialises static JSON data and preserves an explicit MIME type', () => { - expect(contentFromFetchConfig({ data: { enum: ['a', 'b'] }, mimeType: 'application/json' })).toEqual({ - text: '{\n "enum": [\n "a",\n "b"\n ]\n}', + it('does not expose arbitrary data stored beside fetch metadata', () => { + expect(contentFromFetchConfig({ + data: { apiKey: 'must-not-reach-model-context' }, + url: 'https://example.test', mimeType: 'application/json', + })).toEqual({ + text: '[resource content is not available: only local static content is supported]', + }); + }); + + it('preserves explicitly authored static text and content', () => { + expect(contentFromFetchConfig({ text: 'setup notes', mimeType: 'text/markdown' })).toEqual({ + text: 'setup notes', + mimeType: 'text/markdown', + }); + expect(contentFromFetchConfig({ content: 'reference card' })).toEqual({ + text: 'reference card', + mimeType: undefined, }); }); @@ -37,7 +52,7 @@ describe('resource-registry', () => { { uri: 'anythingmcp://x', name: 'x', fetchConfig: {} }, { text: 'hello', mimeType: 'text/plain' }, ); - expect(registerResources(server, [resource, resource], warn)).toBe(1); + expect(registerResources(server as unknown as McpServer, [resource, resource], warn)).toBe(1); expect(warn).toHaveBeenCalledWith(expect.stringContaining('Duplicate MCP resource URI')); const callback = registrations[0][3]; await expect(callback({ href: 'anythingmcp://x' })).resolves.toEqual({ diff --git a/packages/backend/src/mcp-server/resource-registry.ts b/packages/backend/src/mcp-server/resource-registry.ts index f1eb4035..672e1a6e 100644 --- a/packages/backend/src/mcp-server/resource-registry.ts +++ b/packages/backend/src/mcp-server/resource-registry.ts @@ -7,6 +7,8 @@ * turn resources into an SSRF primitive. */ +import { McpServer } from '@modelcontextprotocol/server'; + export interface ResourceDefinition { uri: string; name: string; @@ -19,15 +21,6 @@ export interface RegisteredResource extends ResourceDefinition { read: () => Promise<{ text: string; mimeType: string }>; } -function jsonText(value: unknown): string { - if (typeof value === 'string') return value; - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - /** Convert a persisted fetch config into bounded, local-only content. */ export function contentFromFetchConfig(fetchConfig: unknown): { text: string; @@ -41,9 +34,6 @@ export function contentFromFetchConfig(fetchConfig: unknown): { if (typeof config.content === 'string') { return { text: config.content, mimeType: typeof config.mimeType === 'string' ? config.mimeType : undefined }; } - if ('data' in config) { - return { text: jsonText(config.data), mimeType: typeof config.mimeType === 'string' ? config.mimeType : undefined }; - } } return { text: '[resource content is not available: only local static content is supported]', @@ -66,7 +56,7 @@ export function makeResource( /** Register resources on one per-request MCP server, deduplicating URIs. */ export function registerResources( - server: any, + server: McpServer, resources: RegisteredResource[], warn: (message: string) => void = () => undefined, ): number { @@ -78,13 +68,7 @@ export function registerResources( continue; } seen.add(resource.uri); - const register = server?.registerResource; - if (typeof register !== 'function') { - warn('MCP SDK does not expose registerResource; skipping resource registration'); - break; - } - register.call( - server, + server.registerResource( resource.name, resource.uri, { @@ -92,7 +76,7 @@ export function registerResources( description: resource.description ?? undefined, mimeType: resource.mimeType ?? undefined, }, - async (requestedUri: { href?: string }) => { + async (requestedUri) => { const content = await resource.read(); return { contents: [