Skip to content

Commit 3609a80

Browse files
authored
Merge pull request #1108 from objectstack-ai/copilot/research-repository-architecture
feat: add MCP Runtime Server Plugin (`plugin-mcp-server`)
2 parents 87ace23 + f3a08bc commit 3609a80

File tree

10 files changed

+1375
-11
lines changed

10 files changed

+1375
-11
lines changed

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **MCP Runtime Server Plugin (`plugin-mcp-server`)** — New kernel plugin that exposes ObjectStack
12+
as a Model Context Protocol (MCP) server for external AI clients (Claude Desktop, Cursor, VS Code
13+
Copilot, etc.). Features include:
14+
- **Tool Bridge**: All registered AI tools from `ToolRegistry` (9 built-in tools: `list_objects`,
15+
`describe_object`, `query_records`, `get_record`, `aggregate_data`, `create_object`, `add_field`,
16+
`modify_field`, `delete_field`) are automatically exposed as MCP tools with correct annotations
17+
(readOnlyHint, destructiveHint).
18+
- **Resource Bridge**: Object schemas (`objectstack://objects/{objectName}`), object list
19+
(`objectstack://objects`), record access (`objectstack://objects/{objectName}/records/{recordId}`),
20+
and metadata types (`objectstack://metadata/types`) exposed as MCP resources.
21+
- **Prompt Bridge**: Registered agents (`data_chat`, `metadata_assistant`, etc.) exposed as MCP
22+
prompts with context arguments (objectName, recordId, viewName).
23+
- **Transport**: stdio transport via `@modelcontextprotocol/sdk` for local AI client integration.
24+
- **Environment Configuration**: `MCP_SERVER_ENABLED=true` to auto-start, `MCP_SERVER_NAME` and
25+
`MCP_SERVER_TRANSPORT` for customization.
26+
- **Extensibility**: `mcp:ready` kernel hook allows other plugins to extend the MCP server.
27+
- Studio frontend AI interface remains unchanged — it continues to use REST/SSE via
28+
Vercel Data Stream Protocol.
29+
1030
### Changed
1131
- **Unified `list_objects` / `describe_object` tools (`service-ai`)** — Merged the duplicate
1232
`list_metadata_objects``list_objects` and `describe_metadata_object``describe_object`
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "@objectstack/plugin-mcp-server",
3+
"version": "4.0.2",
4+
"license": "Apache-2.0",
5+
"description": "MCP Runtime Server Plugin for ObjectStack — exposes AI tools, data resources, and agent prompts via the Model Context Protocol",
6+
"type": "module",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"import": "./dist/index.js",
13+
"require": "./dist/index.cjs"
14+
}
15+
},
16+
"scripts": {
17+
"build": "tsup --config ../../../tsup.config.ts",
18+
"test": "vitest run"
19+
},
20+
"dependencies": {
21+
"@modelcontextprotocol/sdk": "^1.29.0",
22+
"@objectstack/core": "workspace:*",
23+
"@objectstack/spec": "workspace:*",
24+
"zod": "^4.3.6"
25+
},
26+
"devDependencies": {
27+
"@types/node": "^25.5.2",
28+
"typescript": "^6.0.2",
29+
"vitest": "^4.1.2"
30+
}
31+
}
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
import { MCPServerRuntime } from '../mcp-server-runtime.js';
5+
import type { MCPServerRuntimeConfig } from '../mcp-server-runtime.js';
6+
import type { AIToolDefinition, ToolCallPart } from '@objectstack/spec/contracts';
7+
import type { ToolRegistry, ToolExecutionResult } from '../types.js';
8+
9+
// ---------------------------------------------------------------------------
10+
// Mock helpers
11+
// ---------------------------------------------------------------------------
12+
13+
function createMockToolRegistry(tools: AIToolDefinition[] = []): ToolRegistry {
14+
const handlers = new Map<string, (args: Record<string, unknown>) => Promise<string>>();
15+
16+
return {
17+
getAll: () => tools,
18+
execute: vi.fn(async (toolCall: ToolCallPart): Promise<ToolExecutionResult> => {
19+
const handler = handlers.get(toolCall.toolName);
20+
if (!handler) {
21+
return {
22+
type: 'tool-result',
23+
toolCallId: toolCall.toolCallId,
24+
toolName: toolCall.toolName,
25+
output: { type: 'text', value: `Tool "${toolCall.toolName}" not found` },
26+
isError: true,
27+
};
28+
}
29+
const args = typeof toolCall.input === 'string'
30+
? JSON.parse(toolCall.input)
31+
: (toolCall.input as Record<string, unknown>) ?? {};
32+
const content = await handler(args);
33+
return {
34+
type: 'tool-result',
35+
toolCallId: toolCall.toolCallId,
36+
toolName: toolCall.toolName,
37+
output: { type: 'text', value: content },
38+
};
39+
}),
40+
// Expose for test setup
41+
_setHandler: (name: string, fn: (args: Record<string, unknown>) => Promise<string>) => {
42+
handlers.set(name, fn);
43+
},
44+
} as ToolRegistry & { _setHandler: (name: string, fn: any) => void };
45+
}
46+
47+
function createMockMetadataService() {
48+
const objects: Record<string, any> = {
49+
account: {
50+
name: 'account',
51+
label: 'Account',
52+
fields: {
53+
name: { type: 'text', label: 'Name', required: true },
54+
email: { type: 'email', label: 'Email' },
55+
status: { type: 'select', label: 'Status' },
56+
},
57+
enable: { softDelete: true },
58+
},
59+
contact: {
60+
name: 'contact',
61+
label: 'Contact',
62+
fields: {
63+
first_name: { type: 'text', label: 'First Name', required: true },
64+
last_name: { type: 'text', label: 'Last Name', required: true },
65+
},
66+
},
67+
};
68+
69+
const agents: Record<string, any> = {
70+
data_chat: {
71+
name: 'data_chat',
72+
label: 'Data Assistant',
73+
role: 'Business Data Analyst',
74+
instructions: 'You are a helpful data assistant.',
75+
active: true,
76+
},
77+
metadata_assistant: {
78+
name: 'metadata_assistant',
79+
label: 'Metadata Assistant',
80+
role: 'Schema Designer',
81+
instructions: 'You help design data schemas.',
82+
active: true,
83+
},
84+
};
85+
86+
return {
87+
listObjects: vi.fn(async () => Object.values(objects)),
88+
getObject: vi.fn(async (name: string) => objects[name] ?? null),
89+
get: vi.fn(async (type: string, name: string) => {
90+
if (type === 'agent') return agents[name] ?? null;
91+
return null;
92+
}),
93+
list: vi.fn(async (type: string) => {
94+
if (type === 'agent') return Object.values(agents);
95+
return [];
96+
}),
97+
exists: vi.fn(async (type: string, name: string) => {
98+
if (type === 'agent') return name in agents;
99+
return false;
100+
}),
101+
getRegisteredTypes: vi.fn(async () => ['object', 'app', 'view', 'agent', 'tool']),
102+
register: vi.fn(),
103+
unregister: vi.fn(),
104+
};
105+
}
106+
107+
function createMockDataEngine() {
108+
const records: Record<string, Record<string, any>> = {
109+
'account:abc123': { id: 'abc123', name: 'Acme Corp', status: 'active' },
110+
'contact:xyz789': { id: 'xyz789', first_name: 'John', last_name: 'Doe' },
111+
};
112+
113+
return {
114+
find: vi.fn(async () => []),
115+
findOne: vi.fn(async (objectName: string, options: any) => {
116+
const recordId = options?.where?.id;
117+
return records[`${objectName}:${recordId}`] ?? null;
118+
}),
119+
insert: vi.fn(),
120+
update: vi.fn(),
121+
delete: vi.fn(),
122+
count: vi.fn(async () => 0),
123+
aggregate: vi.fn(async () => []),
124+
};
125+
}
126+
127+
function createMockLogger() {
128+
return {
129+
info: vi.fn(),
130+
warn: vi.fn(),
131+
error: vi.fn(),
132+
debug: vi.fn(),
133+
};
134+
}
135+
136+
// ---------------------------------------------------------------------------
137+
// Tests
138+
// ---------------------------------------------------------------------------
139+
140+
describe('MCPServerRuntime', () => {
141+
let runtime: MCPServerRuntime;
142+
let mockLogger: ReturnType<typeof createMockLogger>;
143+
144+
beforeEach(() => {
145+
mockLogger = createMockLogger();
146+
runtime = new MCPServerRuntime({
147+
name: 'test-objectstack',
148+
version: '1.0.0-test',
149+
logger: mockLogger as any,
150+
});
151+
});
152+
153+
describe('constructor', () => {
154+
it('should create with default config', () => {
155+
const defaultRuntime = new MCPServerRuntime();
156+
expect(defaultRuntime).toBeDefined();
157+
expect(defaultRuntime.isStarted).toBe(false);
158+
});
159+
160+
it('should create with custom config', () => {
161+
expect(runtime).toBeDefined();
162+
expect(runtime.isStarted).toBe(false);
163+
});
164+
165+
it('should expose the underlying McpServer', () => {
166+
expect(runtime.server).toBeDefined();
167+
});
168+
});
169+
170+
describe('bridgeTools', () => {
171+
it('should bridge all tools from ToolRegistry', () => {
172+
const tools: AIToolDefinition[] = [
173+
{
174+
name: 'list_objects',
175+
description: 'List all objects',
176+
parameters: { type: 'object', properties: {} },
177+
},
178+
{
179+
name: 'query_records',
180+
description: 'Query records',
181+
parameters: { type: 'object', properties: { objectName: { type: 'string' } }, required: ['objectName'] },
182+
},
183+
];
184+
185+
const registry = createMockToolRegistry(tools);
186+
runtime.bridgeTools(registry);
187+
188+
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 2 tools from ToolRegistry');
189+
});
190+
191+
it('should bridge zero tools gracefully', () => {
192+
const registry = createMockToolRegistry([]);
193+
runtime.bridgeTools(registry);
194+
195+
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 0 tools from ToolRegistry');
196+
});
197+
198+
it('should bridge all 9 standard tools', () => {
199+
const standardTools: AIToolDefinition[] = [
200+
{ name: 'create_object', description: 'Create object', parameters: {} },
201+
{ name: 'add_field', description: 'Add field', parameters: {} },
202+
{ name: 'modify_field', description: 'Modify field', parameters: {} },
203+
{ name: 'delete_field', description: 'Delete field', parameters: {} },
204+
{ name: 'list_objects', description: 'List objects', parameters: {} },
205+
{ name: 'describe_object', description: 'Describe object', parameters: {} },
206+
{ name: 'query_records', description: 'Query records', parameters: {} },
207+
{ name: 'get_record', description: 'Get record', parameters: {} },
208+
{ name: 'aggregate_data', description: 'Aggregate data', parameters: {} },
209+
];
210+
211+
const registry = createMockToolRegistry(standardTools);
212+
runtime.bridgeTools(registry);
213+
214+
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 9 tools from ToolRegistry');
215+
});
216+
});
217+
218+
describe('bridgeResources', () => {
219+
it('should bridge metadata resources', () => {
220+
const metadataService = createMockMetadataService();
221+
runtime.bridgeResources(metadataService as any);
222+
223+
// Should register: object_list, object_schema, metadata_types (3 resources, no dataEngine = no record_by_id)
224+
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 3 resource endpoints');
225+
});
226+
227+
it('should bridge record resources when DataEngine is available', () => {
228+
const metadataService = createMockMetadataService();
229+
const dataEngine = createMockDataEngine();
230+
runtime.bridgeResources(metadataService as any, dataEngine as any);
231+
232+
// Should register: object_list, object_schema, record_by_id, metadata_types (4 resources)
233+
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 4 resource endpoints');
234+
});
235+
236+
it('should skip metadata_types when getRegisteredTypes is not available', () => {
237+
const metadataService = createMockMetadataService();
238+
delete (metadataService as any).getRegisteredTypes;
239+
runtime.bridgeResources(metadataService as any);
240+
241+
// Should register: object_list, object_schema only (2 resources)
242+
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 2 resource endpoints');
243+
});
244+
});
245+
246+
describe('bridgePrompts', () => {
247+
it('should register agent prompt', () => {
248+
const metadataService = createMockMetadataService();
249+
runtime.bridgePrompts(metadataService as any);
250+
251+
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Agent prompts bridged');
252+
});
253+
});
254+
255+
describe('lifecycle', () => {
256+
it('should not be started initially', () => {
257+
expect(runtime.isStarted).toBe(false);
258+
});
259+
260+
it('should warn when HTTP transport is requested', async () => {
261+
const httpRuntime = new MCPServerRuntime({
262+
transport: 'http',
263+
logger: mockLogger as any,
264+
});
265+
266+
await httpRuntime.start();
267+
268+
expect(httpRuntime.isStarted).toBe(false);
269+
expect(mockLogger.warn).toHaveBeenCalledWith(
270+
'[MCP] HTTP transport is not yet supported. Use stdio transport.',
271+
);
272+
});
273+
274+
it('should be idempotent on stop when not started', async () => {
275+
await runtime.stop();
276+
expect(runtime.isStarted).toBe(false);
277+
});
278+
});
279+
});

0 commit comments

Comments
 (0)