Skip to content
Merged
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **MCP Runtime Server Plugin (`plugin-mcp-server`)** — New kernel plugin that exposes ObjectStack
as a Model Context Protocol (MCP) server for external AI clients (Claude Desktop, Cursor, VS Code
Copilot, etc.). Features include:
- **Tool Bridge**: All registered AI tools from `ToolRegistry` (9 built-in tools: `list_objects`,
`describe_object`, `query_records`, `get_record`, `aggregate_data`, `create_object`, `add_field`,
`modify_field`, `delete_field`) are automatically exposed as MCP tools with correct annotations
(readOnlyHint, destructiveHint).
- **Resource Bridge**: Object schemas (`objectstack://objects/{objectName}`), object list
(`objectstack://objects`), record access (`objectstack://objects/{objectName}/records/{recordId}`),
and metadata types (`objectstack://metadata/types`) exposed as MCP resources.
- **Prompt Bridge**: Registered agents (`data_chat`, `metadata_assistant`, etc.) exposed as MCP
prompts with context arguments (objectName, recordId, viewName).
- **Transport**: stdio transport via `@modelcontextprotocol/sdk` for local AI client integration.
- **Environment Configuration**: `MCP_SERVER_ENABLED=true` to auto-start, `MCP_SERVER_NAME` and
`MCP_SERVER_TRANSPORT` for customization.
- **Extensibility**: `mcp:ready` kernel hook allows other plugins to extend the MCP server.
- Studio frontend AI interface remains unchanged — it continues to use REST/SSE via
Vercel Data Stream Protocol.

### Changed
- **Unified `list_objects` / `describe_object` tools (`service-ai`)** — Merged the duplicate
`list_metadata_objects` → `list_objects` and `describe_metadata_object` → `describe_object`
Expand Down
31 changes: 31 additions & 0 deletions packages/plugins/plugin-mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@objectstack/plugin-mcp-server",
"version": "4.0.2",
"license": "Apache-2.0",
"description": "MCP Runtime Server Plugin for ObjectStack — exposes AI tools, data resources, and agent prompts via the Model Context Protocol",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsup --config ../../../tsup.config.ts",
"test": "vitest run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@objectstack/core": "workspace:*",
"@objectstack/spec": "workspace:*",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.5.2",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MCPServerRuntime } from '../mcp-server-runtime.js';
import type { MCPServerRuntimeConfig } from '../mcp-server-runtime.js';
import type { AIToolDefinition, ToolCallPart } from '@objectstack/spec/contracts';
import type { ToolRegistry, ToolExecutionResult } from '../types.js';

// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------

function createMockToolRegistry(tools: AIToolDefinition[] = []): ToolRegistry {
const handlers = new Map<string, (args: Record<string, unknown>) => Promise<string>>();

return {
getAll: () => tools,
execute: vi.fn(async (toolCall: ToolCallPart): Promise<ToolExecutionResult> => {
const handler = handlers.get(toolCall.toolName);
if (!handler) {
return {
type: 'tool-result',
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
output: { type: 'text', value: `Tool "${toolCall.toolName}" not found` },
isError: true,
};
}
const args = typeof toolCall.input === 'string'
? JSON.parse(toolCall.input)
: (toolCall.input as Record<string, unknown>) ?? {};
const content = await handler(args);
return {
type: 'tool-result',
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
output: { type: 'text', value: content },
};
}),
// Expose for test setup
_setHandler: (name: string, fn: (args: Record<string, unknown>) => Promise<string>) => {
handlers.set(name, fn);
},
} as ToolRegistry & { _setHandler: (name: string, fn: any) => void };
}

function createMockMetadataService() {
const objects: Record<string, any> = {
account: {
name: 'account',
label: 'Account',
fields: {
name: { type: 'text', label: 'Name', required: true },
email: { type: 'email', label: 'Email' },
status: { type: 'select', label: 'Status' },
},
enable: { softDelete: true },
},
contact: {
name: 'contact',
label: 'Contact',
fields: {
first_name: { type: 'text', label: 'First Name', required: true },
last_name: { type: 'text', label: 'Last Name', required: true },
},
},
};

const agents: Record<string, any> = {
data_chat: {
name: 'data_chat',
label: 'Data Assistant',
role: 'Business Data Analyst',
instructions: 'You are a helpful data assistant.',
active: true,
},
metadata_assistant: {
name: 'metadata_assistant',
label: 'Metadata Assistant',
role: 'Schema Designer',
instructions: 'You help design data schemas.',
active: true,
},
};

return {
listObjects: vi.fn(async () => Object.values(objects)),
getObject: vi.fn(async (name: string) => objects[name] ?? null),
get: vi.fn(async (type: string, name: string) => {
if (type === 'agent') return agents[name] ?? null;
return null;
}),
list: vi.fn(async (type: string) => {
if (type === 'agent') return Object.values(agents);
return [];
}),
exists: vi.fn(async (type: string, name: string) => {
if (type === 'agent') return name in agents;
return false;
}),
getRegisteredTypes: vi.fn(async () => ['object', 'app', 'view', 'agent', 'tool']),
register: vi.fn(),
unregister: vi.fn(),
};
}

function createMockDataEngine() {
const records: Record<string, Record<string, any>> = {
'account:abc123': { id: 'abc123', name: 'Acme Corp', status: 'active' },
'contact:xyz789': { id: 'xyz789', first_name: 'John', last_name: 'Doe' },
};

return {
find: vi.fn(async () => []),
findOne: vi.fn(async (objectName: string, options: any) => {
const recordId = options?.where?.id;
return records[`${objectName}:${recordId}`] ?? null;
}),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(async () => 0),
aggregate: vi.fn(async () => []),
};
}

function createMockLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('MCPServerRuntime', () => {
let runtime: MCPServerRuntime;
let mockLogger: ReturnType<typeof createMockLogger>;

beforeEach(() => {
mockLogger = createMockLogger();
runtime = new MCPServerRuntime({
name: 'test-objectstack',
version: '1.0.0-test',
logger: mockLogger as any,
});
});

describe('constructor', () => {
it('should create with default config', () => {
const defaultRuntime = new MCPServerRuntime();
expect(defaultRuntime).toBeDefined();
expect(defaultRuntime.isStarted).toBe(false);
});

it('should create with custom config', () => {
expect(runtime).toBeDefined();
expect(runtime.isStarted).toBe(false);
});

it('should expose the underlying McpServer', () => {
expect(runtime.server).toBeDefined();
});
});

describe('bridgeTools', () => {
it('should bridge all tools from ToolRegistry', () => {
const tools: AIToolDefinition[] = [
{
name: 'list_objects',
description: 'List all objects',
parameters: { type: 'object', properties: {} },
},
{
name: 'query_records',
description: 'Query records',
parameters: { type: 'object', properties: { objectName: { type: 'string' } }, required: ['objectName'] },
},
];

const registry = createMockToolRegistry(tools);
runtime.bridgeTools(registry);

expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 2 tools from ToolRegistry');
});

it('should bridge zero tools gracefully', () => {
const registry = createMockToolRegistry([]);
runtime.bridgeTools(registry);

expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 0 tools from ToolRegistry');
});

it('should bridge all 9 standard tools', () => {
const standardTools: AIToolDefinition[] = [
{ name: 'create_object', description: 'Create object', parameters: {} },
{ name: 'add_field', description: 'Add field', parameters: {} },
{ name: 'modify_field', description: 'Modify field', parameters: {} },
{ name: 'delete_field', description: 'Delete field', parameters: {} },
{ name: 'list_objects', description: 'List objects', parameters: {} },
{ name: 'describe_object', description: 'Describe object', parameters: {} },
{ name: 'query_records', description: 'Query records', parameters: {} },
{ name: 'get_record', description: 'Get record', parameters: {} },
{ name: 'aggregate_data', description: 'Aggregate data', parameters: {} },
];

const registry = createMockToolRegistry(standardTools);
runtime.bridgeTools(registry);

expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 9 tools from ToolRegistry');
});
});

describe('bridgeResources', () => {
it('should bridge metadata resources', () => {
const metadataService = createMockMetadataService();
runtime.bridgeResources(metadataService as any);

// Should register: object_list, object_schema, metadata_types (3 resources, no dataEngine = no record_by_id)
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 3 resource endpoints');
});

it('should bridge record resources when DataEngine is available', () => {
const metadataService = createMockMetadataService();
const dataEngine = createMockDataEngine();
runtime.bridgeResources(metadataService as any, dataEngine as any);

// Should register: object_list, object_schema, record_by_id, metadata_types (4 resources)
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 4 resource endpoints');
});

it('should skip metadata_types when getRegisteredTypes is not available', () => {
const metadataService = createMockMetadataService();
delete (metadataService as any).getRegisteredTypes;
runtime.bridgeResources(metadataService as any);

// Should register: object_list, object_schema only (2 resources)
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 2 resource endpoints');
});
});

describe('bridgePrompts', () => {
it('should register agent prompt', () => {
const metadataService = createMockMetadataService();
runtime.bridgePrompts(metadataService as any);

expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Agent prompts bridged');
});
});

describe('lifecycle', () => {
it('should not be started initially', () => {
expect(runtime.isStarted).toBe(false);
});

it('should warn when HTTP transport is requested', async () => {
const httpRuntime = new MCPServerRuntime({
transport: 'http',
logger: mockLogger as any,
});

await httpRuntime.start();

expect(httpRuntime.isStarted).toBe(false);
expect(mockLogger.warn).toHaveBeenCalledWith(
'[MCP] HTTP transport is not yet supported. Use stdio transport.',
);
});

it('should be idempotent on stop when not started', async () => {
await runtime.stop();
expect(runtime.isStarted).toBe(false);
});
});
});
Loading