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..43b5abc7 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,108 @@ 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: { content: '{"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'), + }); + }); + + 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 81920df8..cf2891b8 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). @@ -123,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; @@ -664,9 +678,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 +705,19 @@ 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.planRoleScopedResources( + serverId, + resourceConnectors, + instructions, + serverTools, + allowedToolIds, + ); + 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 +785,67 @@ export class McpEndpointController { await this.serveStateless(req, res, body, () => mcpServer, `server ${serverId}`); } + private planResources( + serverId: string, + connectors: ResourceConnector[], + 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; + } + + 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 new file mode 100644 index 00000000..a31d0f6d --- /dev/null +++ b/packages/backend/src/mcp-server/resource-registry.spec.ts @@ -0,0 +1,62 @@ +import { + contentFromFetchConfig, + makeResource, + registerResources, +} from './resource-registry'; +import type { McpServer } from '@modelcontextprotocol/server'; + +describe('resource-registry', () => { + 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, + }); + }); + + 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 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({ + 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..672e1a6e --- /dev/null +++ b/packages/backend/src/mcp-server/resource-registry.ts @@ -0,0 +1,95 @@ +/** + * 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. + */ + +import { McpServer } from '@modelcontextprotocol/server'; + +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 }>; +} + +/** 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 }; + } + } + 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: McpServer, + 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); + server.registerResource( + resource.name, + resource.uri, + { + title: resource.name, + description: resource.description ?? undefined, + mimeType: resource.mimeType ?? undefined, + }, + async (requestedUri) => { + 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.