Skip to content
Draft
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
17 changes: 17 additions & 0 deletions docs/mcp-resources.md
Original file line number Diff line number Diff line change
@@ -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.
106 changes: 106 additions & 0 deletions packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
Expand Down Expand Up @@ -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',
);
});
});
91 changes: 90 additions & 1 deletion packages/backend/src/mcp-server/mcp-endpoint.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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.
*
Expand Down
62 changes: 62 additions & 0 deletions packages/backend/src/mcp-server/resource-registry.spec.ts
Original file line number Diff line number Diff line change
@@ -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' }],
});
});
});
Loading
Loading