From a4075995569c8a8c362d102b513e058b297a2b2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:36:24 +0000 Subject: [PATCH 1/4] feat: add plugin-mcp-server with MCPServerRuntime and MCPServerPlugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the MCP Runtime Server plugin that bridges ObjectStack kernel services to the Model Context Protocol (MCP). Features: - Tool bridge: all ToolRegistry tools → MCP tools - Resource bridge: MetadataService → object schemas, DataEngine → records - Prompt bridge: Agent definitions → MCP prompts - StdioServerTransport for local AI client integration - Environment variable configuration (MCP_SERVER_ENABLED, MCP_SERVER_NAME) Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/5eb90a65-846f-4f24-947f-1c0e7ea5090e Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../plugins/plugin-mcp-server/package.json | 30 ++ .../plugins/plugin-mcp-server/src/index.ts | 15 + .../src/mcp-server-runtime.ts | 472 ++++++++++++++++++ .../plugins/plugin-mcp-server/src/plugin.ts | 135 +++++ .../plugins/plugin-mcp-server/src/types.ts | 24 + .../plugins/plugin-mcp-server/tsconfig.json | 18 + pnpm-lock.yaml | 123 ++++- 7 files changed, 806 insertions(+), 11 deletions(-) create mode 100644 packages/plugins/plugin-mcp-server/package.json create mode 100644 packages/plugins/plugin-mcp-server/src/index.ts create mode 100644 packages/plugins/plugin-mcp-server/src/mcp-server-runtime.ts create mode 100644 packages/plugins/plugin-mcp-server/src/plugin.ts create mode 100644 packages/plugins/plugin-mcp-server/src/types.ts create mode 100644 packages/plugins/plugin-mcp-server/tsconfig.json diff --git a/packages/plugins/plugin-mcp-server/package.json b/packages/plugins/plugin-mcp-server/package.json new file mode 100644 index 0000000000..2b3105d3c3 --- /dev/null +++ b/packages/plugins/plugin-mcp-server/package.json @@ -0,0 +1,30 @@ +{ + "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:*" + }, + "devDependencies": { + "@types/node": "^25.5.2", + "typescript": "^6.0.2", + "vitest": "^4.1.2" + } +} diff --git a/packages/plugins/plugin-mcp-server/src/index.ts b/packages/plugins/plugin-mcp-server/src/index.ts new file mode 100644 index 0000000000..726df1b6c7 --- /dev/null +++ b/packages/plugins/plugin-mcp-server/src/index.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/plugin-mcp-server + * + * MCP Runtime Server Plugin for ObjectStack. + * Exposes all registered AI tools, data resources, and agent prompts + * via the Model Context Protocol (MCP) for use by external AI clients + * (Claude Desktop, Cursor, VS Code Copilot, etc.). + */ + +export { MCPServerPlugin } from './plugin.js'; +export type { MCPServerPluginOptions } from './plugin.js'; +export { MCPServerRuntime } from './mcp-server-runtime.js'; +export type { MCPServerRuntimeConfig } from './mcp-server-runtime.js'; diff --git a/packages/plugins/plugin-mcp-server/src/mcp-server-runtime.ts b/packages/plugins/plugin-mcp-server/src/mcp-server-runtime.ts new file mode 100644 index 0000000000..eeba2b5358 --- /dev/null +++ b/packages/plugins/plugin-mcp-server/src/mcp-server-runtime.ts @@ -0,0 +1,472 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import type { Logger, IMetadataService, IDataEngine, AIToolDefinition } from '@objectstack/spec/contracts'; +import type { Agent } from '@objectstack/spec'; +import type { ToolRegistry } from './types.js'; + +/** + * Configuration for the MCP Server Runtime. + */ +export interface MCPServerRuntimeConfig { + /** Human-readable server name. */ + name?: string; + /** Server version (semver). */ + version?: string; + /** Optional instructions describing how to use the server. */ + instructions?: string; + /** Transport mode: 'stdio' (default) or 'http'. */ + transport?: 'stdio' | 'http'; + /** Logger instance. */ + logger?: Logger; +} + +/** + * Minimal shape of an object definition returned by IMetadataService. + */ +interface ObjectDef { + name: string; + label?: string; + fields?: Record; + enable?: Record; +} + +/** + * MCPServerRuntime — Bridges ObjectStack kernel services to the Model Context Protocol. + * + * Responsibilities: + * 1. Bridge ToolRegistry → MCP tools (all registered AI tools) + * 2. Bridge IMetadataService → MCP resources (object schemas, metadata types) + * 3. Bridge IDataEngine → MCP resources (record access by URI) + * 4. Bridge Agent definitions → MCP prompts (agent instructions) + * + * Architecture: + * ``` + * ToolRegistry (service-ai) ──┐ + * IMetadataService (metadata) ─┼──→ MCPServerRuntime ──→ McpServer (SDK) + * IDataEngine (objectql) ──┤ │ + * Agent definitions ──┘ ├── stdio transport + * └── http transport (future) + * ``` + */ +export class MCPServerRuntime { + private readonly mcpServer: McpServer; + private readonly config: Required> & MCPServerRuntimeConfig; + private transport: StdioServerTransport | undefined; + private started = false; + + constructor(config: MCPServerRuntimeConfig = {}) { + this.config = { + name: 'objectstack', + version: '1.0.0', + transport: 'stdio', + ...config, + }; + + this.mcpServer = new McpServer( + { + name: this.config.name, + version: this.config.version, + }, + { + capabilities: { + resources: {}, + tools: {}, + prompts: {}, + logging: {}, + }, + instructions: this.config.instructions ?? 'ObjectStack MCP Server — access data objects, AI tools, and agent prompts.', + }, + ); + } + + /** The underlying McpServer instance (for advanced use cases). */ + get server(): McpServer { + return this.mcpServer; + } + + /** Whether the server is currently connected and running. */ + get isStarted(): boolean { + return this.started; + } + + // ── Tool Bridge ──────────────────────────────────────────────── + + /** + * Bridge all tools from the ToolRegistry to MCP tools. + * + * Each registered tool becomes an MCP tool with the same name, description, + * and JSON Schema parameters. The handler delegates to the ToolRegistry's + * execute path. + */ + bridgeTools(toolRegistry: ToolRegistry): void { + const tools = toolRegistry.getAll(); + const logger = this.config.logger; + + for (const tool of tools) { + this.registerToolFromDefinition(tool, toolRegistry); + } + + logger?.info(`[MCP] Bridged ${tools.length} tools from ToolRegistry`); + } + + /** + * Register a single tool on the MCP server from an AIToolDefinition. + */ + private registerToolFromDefinition(tool: AIToolDefinition, toolRegistry: ToolRegistry): void { + const logger = this.config.logger; + + // Convert JSON Schema parameters to Zod-compatible format for MCP SDK + // The MCP SDK registerTool with inputSchema expects a Zod raw shape or AnySchema. + // Since our tools use JSON Schema, we use the low-level .tool() with a raw callback + // and pass the JSON Schema as annotations metadata. + this.mcpServer.registerTool( + tool.name, + { + description: tool.description, + annotations: { + // Mark tools with write side-effects for destructive operations + destructiveHint: this.isDestructiveTool(tool.name), + readOnlyHint: this.isReadOnlyTool(tool.name), + openWorldHint: false, + }, + }, + async (extra) => { + // MCP SDK passes the raw arguments when no inputSchema is defined + // We need to extract them from the request + const args = (extra as any)?.arguments ?? (extra as any)?.params?.arguments ?? {}; + + try { + const result = await toolRegistry.execute({ + type: 'tool-call', + toolCallId: `mcp-${tool.name}-${Date.now()}`, + toolName: tool.name, + input: args, + }); + + const outputText = result.output && typeof result.output === 'object' && 'value' in result.output + ? String(result.output.value) + : JSON.stringify(result.output ?? ''); + + if (result.isError) { + return { + content: [{ type: 'text' as const, text: outputText }], + isError: true, + }; + } + + return { + content: [{ type: 'text' as const, text: outputText }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger?.warn(`[MCP] Tool "${tool.name}" execution failed:`, { error: message }); + return { + content: [{ type: 'text' as const, text: message }], + isError: true, + }; + } + }, + ); + } + + /** + * Check if a tool is read-only (data query tools). + */ + private isReadOnlyTool(name: string): boolean { + return ['list_objects', 'describe_object', 'query_records', 'get_record', 'aggregate_data'].includes(name); + } + + /** + * Check if a tool performs destructive operations. + */ + private isDestructiveTool(name: string): boolean { + return ['delete_field'].includes(name); + } + + // ── Resource Bridge ──────────────────────────────────────────── + + /** + * Bridge metadata service and data engine to MCP resources. + * + * Exposes: + * - `objectstack://objects` — List all data objects + * - `objectstack://objects/{objectName}` — Get object schema + * - `objectstack://objects/{objectName}/records/{recordId}` — Get a specific record + * - `objectstack://metadata/types` — List all metadata types + */ + bridgeResources(metadataService: IMetadataService, dataEngine?: IDataEngine): void { + const logger = this.config.logger; + let resourceCount = 0; + + // ── Static resource: List all objects ── + this.mcpServer.registerResource( + 'object_list', + 'objectstack://objects', + { + description: 'List all data objects (tables) in the ObjectStack instance', + mimeType: 'application/json', + }, + async () => { + const objects = await metadataService.listObjects(); + const summary = (objects as ObjectDef[]).map(o => ({ + name: o.name, + label: o.label ?? o.name, + fieldCount: o.fields ? Object.keys(o.fields).length : 0, + })); + + return { + contents: [{ + uri: 'objectstack://objects', + mimeType: 'application/json', + text: JSON.stringify({ objects: summary, totalCount: summary.length }, null, 2), + }], + }; + }, + ); + resourceCount++; + + // ── Template resource: Object schema ── + this.mcpServer.registerResource( + 'object_schema', + new ResourceTemplate('objectstack://objects/{objectName}', { list: undefined }), + { + description: 'Get the full schema of a specific data object including fields and features', + mimeType: 'application/json', + }, + async (_uri, variables) => { + const objectName = String(variables.objectName); + const objectDef = await metadataService.getObject(objectName); + + if (!objectDef) { + return { + contents: [{ + uri: `objectstack://objects/${objectName}`, + mimeType: 'application/json', + text: JSON.stringify({ error: `Object "${objectName}" not found` }), + }], + }; + } + + const def = objectDef as ObjectDef; + const fields = def.fields ?? {}; + const fieldSummary = Object.entries(fields).map(([key, f]) => ({ + name: key, + type: f.type, + label: f.label ?? key, + required: f.required ?? false, + })); + + return { + contents: [{ + uri: `objectstack://objects/${objectName}`, + mimeType: 'application/json', + text: JSON.stringify({ + name: def.name, + label: def.label ?? def.name, + fields: fieldSummary, + enableFeatures: def.enable ?? {}, + }, null, 2), + }], + }; + }, + ); + resourceCount++; + + // ── Template resource: Record by ID ── + if (dataEngine) { + this.mcpServer.registerResource( + 'record_by_id', + new ResourceTemplate('objectstack://objects/{objectName}/records/{recordId}', { list: undefined }), + { + description: 'Get a specific record by ID from a data object', + mimeType: 'application/json', + }, + async (_uri, variables) => { + const objectName = String(variables.objectName); + const recordId = String(variables.recordId); + + try { + const record = await dataEngine.findOne(objectName, { + where: { id: recordId }, + }); + + if (!record) { + return { + contents: [{ + uri: `objectstack://objects/${objectName}/records/${recordId}`, + mimeType: 'application/json', + text: JSON.stringify({ error: `Record "${recordId}" not found in "${objectName}"` }), + }], + }; + } + + return { + contents: [{ + uri: `objectstack://objects/${objectName}/records/${recordId}`, + mimeType: 'application/json', + text: JSON.stringify(record, null, 2), + }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + contents: [{ + uri: `objectstack://objects/${objectName}/records/${recordId}`, + mimeType: 'application/json', + text: JSON.stringify({ error: message }), + }], + }; + } + }, + ); + resourceCount++; + } + + // ── Static resource: Metadata types ── + if (metadataService.getRegisteredTypes) { + this.mcpServer.registerResource( + 'metadata_types', + 'objectstack://metadata/types', + { + description: 'List all registered metadata types (object, app, view, agent, tool, etc.)', + mimeType: 'application/json', + }, + async () => { + const types = await metadataService.getRegisteredTypes!(); + return { + contents: [{ + uri: 'objectstack://metadata/types', + mimeType: 'application/json', + text: JSON.stringify({ types, totalCount: types.length }, null, 2), + }], + }; + }, + ); + resourceCount++; + } + + logger?.info(`[MCP] Bridged ${resourceCount} resource endpoints`); + } + + // ── Prompt Bridge ────────────────────────────────────────────── + + /** + * Bridge registered agents to MCP prompts. + * + * Each active agent becomes an MCP prompt with: + * - Name matching the agent name + * - System message from agent instructions + * - Optional context arguments (objectName, recordId, viewName) + */ + bridgePrompts(metadataService: IMetadataService): void { + const logger = this.config.logger; + + // Register a dynamic prompt that loads agents at call time + this.mcpServer.registerPrompt( + 'agent_prompt', + { + description: 'Load an agent\'s system prompt with optional UI context. ' + + 'Use the agentName argument to select which agent\'s instructions to use.', + argsSchema: { + agentName: { + type: 'string' as any, + description: 'Name of the agent to load (e.g. "data_chat", "metadata_assistant")', + }, + objectName: { + type: 'string' as any, + description: 'Current object the user is viewing', + }, + recordId: { + type: 'string' as any, + description: 'Currently selected record ID', + }, + viewName: { + type: 'string' as any, + description: 'Current view name', + }, + }, + }, + async (args) => { + const agentName = String(args.agentName ?? ''); + if (!agentName) { + return { + messages: [{ + role: 'user' as const, + content: { type: 'text' as const, text: 'Error: agentName argument is required' }, + }], + }; + } + + const raw = await metadataService.get('agent', agentName); + if (!raw) { + return { + messages: [{ + role: 'user' as const, + content: { type: 'text' as const, text: `Error: Agent "${agentName}" not found` }, + }], + }; + } + + const agent = raw as Agent; + + // Build system prompt from agent instructions + context + const parts: string[] = []; + parts.push(agent.instructions ?? ''); + + const contextHints: string[] = []; + if (args.objectName) contextHints.push(`Current object: ${args.objectName}`); + if (args.recordId) contextHints.push(`Selected record ID: ${args.recordId}`); + if (args.viewName) contextHints.push(`Current view: ${args.viewName}`); + if (contextHints.length > 0) { + parts.push('\n--- Current Context ---\n' + contextHints.join('\n')); + } + + return { + messages: [{ + role: 'assistant' as const, + content: { type: 'text' as const, text: parts.join('\n') }, + }], + }; + }, + ); + + logger?.info('[MCP] Agent prompts bridged'); + } + + // ── Lifecycle ────────────────────────────────────────────────── + + /** + * Start the MCP server with the configured transport. + * + * For stdio transport, this connects to process stdin/stdout. + */ + async start(): Promise { + if (this.started) return; + + const logger = this.config.logger; + + if (this.config.transport === 'stdio') { + this.transport = new StdioServerTransport(); + await this.mcpServer.connect(this.transport); + this.started = true; + logger?.info(`[MCP] Server started (transport: stdio, name: ${this.config.name})`); + } else { + // HTTP transport support will be added in a future version + logger?.warn('[MCP] HTTP transport is not yet supported. Use stdio transport.'); + } + } + + /** + * Stop the MCP server and disconnect the transport. + */ + async stop(): Promise { + if (!this.started) return; + + await this.mcpServer.close(); + this.transport = undefined; + this.started = false; + this.config.logger?.info('[MCP] Server stopped'); + } +} diff --git a/packages/plugins/plugin-mcp-server/src/plugin.ts b/packages/plugins/plugin-mcp-server/src/plugin.ts new file mode 100644 index 0000000000..bceef962be --- /dev/null +++ b/packages/plugins/plugin-mcp-server/src/plugin.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import type { MCPServerRuntimeConfig } from './mcp-server-runtime.js'; +import type { ToolRegistry } from './types.js'; + +/** + * Configuration options for the MCPServerPlugin. + */ +export interface MCPServerPluginOptions { + /** Override MCP server name. Defaults to 'objectstack'. */ + name?: string; + /** Override MCP server version. Defaults to package version. */ + version?: string; + /** Transport mode: 'stdio' (default). */ + transport?: 'stdio' | 'http'; + /** Whether to auto-start the MCP server. Defaults to false (manual start via env var). */ + autoStart?: boolean; + /** Custom instructions for the MCP server. */ + instructions?: string; +} + +/** + * MCPServerPlugin — Kernel plugin that exposes ObjectStack as an MCP server. + * + * Lifecycle: + * 1. **init** — Creates {@link MCPServerRuntime} and registers as `'mcp'` service. + * 2. **start** — Bridges ToolRegistry, MetadataService, DataEngine, and Agents + * to the MCP server. Starts the transport if `autoStart` is enabled or + * the `MCP_SERVER_ENABLED` environment variable is set. + * 3. **destroy** — Stops the MCP transport. + * + * Environment Variables: + * - `MCP_SERVER_ENABLED=true` — Enable MCP server at startup + * - `MCP_SERVER_NAME` — Override server name + * - `MCP_SERVER_TRANSPORT` — Override transport ('stdio' | 'http') + * + * @example + * ```ts + * import { LiteKernel } from '@objectstack/core'; + * import { MCPServerPlugin } from '@objectstack/plugin-mcp-server'; + * + * const kernel = new LiteKernel(); + * kernel.use(new MCPServerPlugin({ autoStart: true })); + * await kernel.bootstrap(); + * ``` + */ +export class MCPServerPlugin implements Plugin { + name = 'com.objectstack.plugin-mcp-server'; + version = '1.0.0'; + type = 'standard' as const; + dependencies: string[] = []; + + private runtime?: MCPServerRuntime; + private readonly options: MCPServerPluginOptions; + + constructor(options: MCPServerPluginOptions = {}) { + this.options = options; + } + + async init(ctx: PluginContext): Promise { + const config: MCPServerRuntimeConfig = { + name: process.env.MCP_SERVER_NAME ?? this.options.name ?? 'objectstack', + version: this.options.version ?? '1.0.0', + transport: (process.env.MCP_SERVER_TRANSPORT as 'stdio' | 'http') ?? this.options.transport ?? 'stdio', + instructions: this.options.instructions, + logger: ctx.logger, + }; + + this.runtime = new MCPServerRuntime(config); + ctx.registerService('mcp', this.runtime); + + ctx.logger.info('[MCP] Plugin initialized'); + } + + async start(ctx: PluginContext): Promise { + if (!this.runtime) return; + + // ── Bridge tools from AIService ── + try { + const aiService = ctx.getService('ai'); + if (aiService?.toolRegistry) { + this.runtime.bridgeTools(aiService.toolRegistry); + } else { + ctx.logger.debug('[MCP] AI service does not expose a toolRegistry, skipping tool bridging'); + } + } catch { + ctx.logger.debug('[MCP] AI service not available, skipping tool bridging'); + } + + // ── Bridge resources from MetadataService & DataEngine ── + let metadataService: IMetadataService | undefined; + let dataEngine: IDataEngine | undefined; + + try { + metadataService = ctx.getService('metadata'); + } catch { + ctx.logger.debug('[MCP] Metadata service not available, skipping resource bridging'); + } + + try { + dataEngine = ctx.getService('data'); + } catch { + ctx.logger.debug('[MCP] Data engine not available, skipping record resources'); + } + + if (metadataService) { + this.runtime.bridgeResources(metadataService, dataEngine); + this.runtime.bridgePrompts(metadataService); + } + + // ── Auto-start if configured ── + const shouldStart = this.options.autoStart || process.env.MCP_SERVER_ENABLED === 'true'; + if (shouldStart) { + await this.runtime.start(); + ctx.logger.info('[MCP] Server started automatically'); + } else { + ctx.logger.info( + '[MCP] Server ready but not started. Set MCP_SERVER_ENABLED=true or use autoStart option.', + ); + } + + // Trigger hook for other plugins to extend MCP + await ctx.trigger('mcp:ready', this.runtime); + } + + async destroy(): Promise { + if (this.runtime?.isStarted) { + await this.runtime.stop(); + } + this.runtime = undefined; + } +} diff --git a/packages/plugins/plugin-mcp-server/src/types.ts b/packages/plugins/plugin-mcp-server/src/types.ts new file mode 100644 index 0000000000..ba606c96a3 --- /dev/null +++ b/packages/plugins/plugin-mcp-server/src/types.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { AIToolDefinition, ToolCallPart, ToolResultPart } from '@objectstack/spec/contracts'; + +/** + * Minimal ToolRegistry interface consumed by the MCP bridge. + * + * Matches the public API of `ToolRegistry` from `@objectstack/service-ai` + * without creating a hard dependency on that package. + */ +export interface ToolRegistry { + /** Return all registered tool definitions. */ + getAll(): AIToolDefinition[]; + + /** Execute a tool call and return the result. */ + execute(toolCall: ToolCallPart): Promise; +} + +/** + * Extended ToolResultPart with isError flag, matching service-ai's ToolExecutionResult. + */ +export interface ToolExecutionResult extends ToolResultPart { + isError?: boolean; +} diff --git a/packages/plugins/plugin-mcp-server/tsconfig.json b/packages/plugins/plugin-mcp-server/tsconfig.json new file mode 100644 index 0000000000..f6a1e8bad5 --- /dev/null +++ b/packages/plugins/plugin-mcp-server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "dist", + "node_modules", + "**/*.test.ts" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bca281126..6e4c84e80c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -960,6 +960,28 @@ importers: specifier: ^4.1.2 version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(happy-dom@20.8.9)(msw@2.12.14(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.5(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/plugins/plugin-mcp-server: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vitest: + specifier: ^4.1.2 + version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(happy-dom@20.8.9)(msw@2.12.14(@types/node@25.5.2)(typescript@6.0.2))(vite@8.0.5(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/plugins/plugin-msw: dependencies: '@objectstack/core': @@ -2286,6 +2308,16 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@mongodb-js/saslprep@1.4.6': resolution: {integrity: sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==} @@ -3629,9 +3661,6 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@25.5.0': - resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} - '@types/node@25.5.2': resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} @@ -4289,6 +4318,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -4700,6 +4733,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -4708,6 +4745,12 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.3.2: + resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -5203,6 +5246,10 @@ packages: resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} engines: {node: '>= 0.10'} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -5363,6 +5410,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -6318,6 +6368,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -7521,6 +7575,11 @@ packages: zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -8428,6 +8487,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.12(hono@4.12.11) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.2(express@5.2.1) + hono: 4.12.11 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@mongodb-js/saslprep@1.4.6': dependencies: sparse-bitfield: 3.0.3 @@ -9740,10 +9821,6 @@ snapshots: '@types/node@12.20.55': {} - '@types/node@25.5.0': - dependencies: - undici-types: 7.18.2 - '@types/node@25.5.2': dependencies: undici-types: 7.18.2 @@ -10205,7 +10282,7 @@ snapshots: dependencies: chokidar: 4.0.3 confbox: 0.2.4 - defu: 6.1.4 + defu: 6.1.6 dotenv: 16.6.1 exsolve: 1.0.8 giget: 2.0.0 @@ -10378,6 +10455,11 @@ snapshots: cookie@1.1.1: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + create-require@1.1.1: {} cross-fetch@4.1.0: @@ -10732,10 +10814,19 @@ snapshots: eventsource-parser@3.0.6: {} + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + expand-template@2.0.3: {} expect-type@1.3.0: {} + express-rate-limit@8.3.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -11098,7 +11189,7 @@ snapshots: dependencies: citty: 0.1.6 consola: 3.4.2 - defu: 6.1.4 + defu: 6.1.6 node-fetch-native: 1.6.7 nypm: 0.6.5 pathe: 2.0.3 @@ -11171,7 +11262,7 @@ snapshots: happy-dom@20.8.9: dependencies: - '@types/node': 25.5.0 + '@types/node': 25.5.2 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -11388,6 +11479,8 @@ snapshots: interpret@2.2.0: {} + ip-address@10.1.0: {} + ipaddr.js@1.9.1: {} ipaddr.js@2.3.0: {} @@ -11516,6 +11609,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json5@2.2.3: {} @@ -12609,6 +12704,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -12759,7 +12856,7 @@ snapshots: rc9@2.1.2: dependencies: - defu: 6.1.4 + defu: 6.1.6 destr: 2.0.5 optional: true @@ -13897,6 +13994,10 @@ snapshots: zimmerframe@1.1.4: {} + zod-to-json-schema@3.25.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@4.3.6: {} zwitch@2.0.4: {} From 57a1c9bcff6f64276106b577a371f8b4eab3acf9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:39:25 +0000 Subject: [PATCH 2/4] test: add comprehensive tests for MCPServerPlugin and MCPServerRuntime 25 tests covering: - MCPServerRuntime: constructor, bridgeTools, bridgeResources, bridgePrompts, lifecycle - MCPServerPlugin: metadata, init, start (with/without services), destroy - Graceful degradation when services are missing Also updates CHANGELOG.md with new MCP Runtime Server Plugin feature. Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/5eb90a65-846f-4f24-947f-1c0e7ea5090e Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- CHANGELOG.md | 20 ++ .../src/__tests__/mcp-server-runtime.test.ts | 279 ++++++++++++++++++ .../src/__tests__/plugin.test.ts | 239 +++++++++++++++ 3 files changed, 538 insertions(+) create mode 100644 packages/plugins/plugin-mcp-server/src/__tests__/mcp-server-runtime.test.ts create mode 100644 packages/plugins/plugin-mcp-server/src/__tests__/plugin.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 812a91343f..4f50d47b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/packages/plugins/plugin-mcp-server/src/__tests__/mcp-server-runtime.test.ts b/packages/plugins/plugin-mcp-server/src/__tests__/mcp-server-runtime.test.ts new file mode 100644 index 0000000000..55727d6867 --- /dev/null +++ b/packages/plugins/plugin-mcp-server/src/__tests__/mcp-server-runtime.test.ts @@ -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) => Promise>(); + + return { + getAll: () => tools, + execute: vi.fn(async (toolCall: ToolCallPart): Promise => { + 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) ?? {}; + 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) => Promise) => { + handlers.set(name, fn); + }, + } as ToolRegistry & { _setHandler: (name: string, fn: any) => void }; +} + +function createMockMetadataService() { + const objects: Record = { + 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 = { + 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> = { + '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; + + 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); + }); + }); +}); diff --git a/packages/plugins/plugin-mcp-server/src/__tests__/plugin.test.ts b/packages/plugins/plugin-mcp-server/src/__tests__/plugin.test.ts new file mode 100644 index 0000000000..308c74523e --- /dev/null +++ b/packages/plugins/plugin-mcp-server/src/__tests__/plugin.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MCPServerPlugin } from '../plugin.js'; + +// --------------------------------------------------------------------------- +// Mock PluginContext +// --------------------------------------------------------------------------- + +function createMockPluginContext(services: Record = {}) { + const serviceRegistry = new Map(Object.entries(services)); + + return { + registerService: vi.fn((name: string, service: any) => { + serviceRegistry.set(name, service); + }), + getService: vi.fn((name: string): T => { + if (!serviceRegistry.has(name)) { + throw new Error(`Service "${name}" not found`); + } + return serviceRegistry.get(name) as T; + }), + replaceService: vi.fn(), + getServices: vi.fn(() => serviceRegistry), + hook: vi.fn(), + trigger: vi.fn(async () => {}), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + getKernel: vi.fn(() => ({})), + }; +} + +function createMockAIService() { + return { + chat: vi.fn(), + complete: vi.fn(), + toolRegistry: { + getAll: () => [ + { name: 'list_objects', description: 'List objects', parameters: {} }, + { name: 'query_records', description: 'Query records', parameters: {} }, + ], + execute: vi.fn(async () => ({ + type: 'tool-result', + toolCallId: 'test', + toolName: 'test', + output: { type: 'text', value: '{}' }, + })), + }, + }; +} + +function createMockMetadataService() { + return { + listObjects: vi.fn(async () => []), + getObject: vi.fn(async () => null), + get: vi.fn(async () => null), + list: vi.fn(async () => []), + exists: vi.fn(async () => false), + getRegisteredTypes: vi.fn(async () => ['object', 'agent']), + register: vi.fn(), + unregister: vi.fn(), + }; +} + +function createMockDataEngine() { + return { + find: vi.fn(async () => []), + findOne: vi.fn(async () => null), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(async () => 0), + aggregate: vi.fn(async () => []), + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('MCPServerPlugin', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + // Ensure MCP_SERVER_ENABLED is NOT set unless explicitly done in a test + delete process.env.MCP_SERVER_ENABLED; + delete process.env.MCP_SERVER_NAME; + delete process.env.MCP_SERVER_TRANSPORT; + }); + + describe('metadata', () => { + it('should have correct plugin metadata', () => { + const plugin = new MCPServerPlugin(); + expect(plugin.name).toBe('com.objectstack.plugin-mcp-server'); + expect(plugin.version).toBe('1.0.0'); + expect(plugin.type).toBe('standard'); + }); + }); + + describe('init', () => { + it('should register MCP service on init', async () => { + const plugin = new MCPServerPlugin(); + const ctx = createMockPluginContext(); + + await plugin.init(ctx as any); + + expect(ctx.registerService).toHaveBeenCalledWith('mcp', expect.any(Object)); + expect(ctx.logger.info).toHaveBeenCalledWith('[MCP] Plugin initialized'); + }); + + it('should respect MCP_SERVER_NAME env var', async () => { + process.env.MCP_SERVER_NAME = 'custom-name'; + const plugin = new MCPServerPlugin(); + const ctx = createMockPluginContext(); + + await plugin.init(ctx as any); + + const registeredRuntime = (ctx.registerService as any).mock.calls[0][1]; + expect(registeredRuntime).toBeDefined(); + }); + + it('should use plugin option name when env var not set', async () => { + const plugin = new MCPServerPlugin({ name: 'my-mcp-server' }); + const ctx = createMockPluginContext(); + + await plugin.init(ctx as any); + + expect(ctx.registerService).toHaveBeenCalledWith('mcp', expect.any(Object)); + }); + }); + + describe('start', () => { + it('should bridge tools when AI service is available', async () => { + const aiService = createMockAIService(); + const metadataService = createMockMetadataService(); + const dataEngine = createMockDataEngine(); + + const ctx = createMockPluginContext({ + ai: aiService, + metadata: metadataService, + data: dataEngine, + }); + + const plugin = new MCPServerPlugin(); + await plugin.init(ctx as any); + await plugin.start(ctx as any); + + expect(ctx.logger.info).toHaveBeenCalledWith( + expect.stringContaining('[MCP] Server ready but not started'), + ); + expect(ctx.trigger).toHaveBeenCalledWith('mcp:ready', expect.any(Object)); + }); + + it('should handle missing AI service gracefully', async () => { + const metadataService = createMockMetadataService(); + const ctx = createMockPluginContext({ metadata: metadataService }); + + const plugin = new MCPServerPlugin(); + await plugin.init(ctx as any); + await plugin.start(ctx as any); + + expect(ctx.logger.debug).toHaveBeenCalledWith( + '[MCP] AI service not available, skipping tool bridging', + ); + }); + + it('should handle missing metadata service gracefully', async () => { + const aiService = createMockAIService(); + const ctx = createMockPluginContext({ ai: aiService }); + + const plugin = new MCPServerPlugin(); + await plugin.init(ctx as any); + await plugin.start(ctx as any); + + expect(ctx.logger.debug).toHaveBeenCalledWith( + '[MCP] Metadata service not available, skipping resource bridging', + ); + }); + + it('should handle missing data engine gracefully', async () => { + const aiService = createMockAIService(); + const metadataService = createMockMetadataService(); + const ctx = createMockPluginContext({ ai: aiService, metadata: metadataService }); + + const plugin = new MCPServerPlugin(); + await plugin.init(ctx as any); + await plugin.start(ctx as any); + + expect(ctx.logger.debug).toHaveBeenCalledWith( + '[MCP] Data engine not available, skipping record resources', + ); + }); + + it('should not auto-start when MCP_SERVER_ENABLED is not set', async () => { + const ctx = createMockPluginContext(); + + const plugin = new MCPServerPlugin(); + await plugin.init(ctx as any); + await plugin.start(ctx as any); + + expect(ctx.logger.info).toHaveBeenCalledWith( + expect.stringContaining('[MCP] Server ready but not started'), + ); + }); + + it('should trigger mcp:ready hook', async () => { + const ctx = createMockPluginContext(); + + const plugin = new MCPServerPlugin(); + await plugin.init(ctx as any); + await plugin.start(ctx as any); + + expect(ctx.trigger).toHaveBeenCalledWith('mcp:ready', expect.any(Object)); + }); + }); + + describe('destroy', () => { + it('should clean up on destroy', async () => { + const ctx = createMockPluginContext(); + + const plugin = new MCPServerPlugin(); + await plugin.init(ctx as any); + + // Should not throw + await plugin.destroy(); + }); + + it('should handle destroy without init', async () => { + const plugin = new MCPServerPlugin(); + // Should not throw + await plugin.destroy(); + }); + }); +}); From a31c29855858a9dac7cbec063f94f3b68db7faa7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:43:54 +0000 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20address=20code=20review=20feedb?= =?UTF-8?q?ack=20=E2=80=94=20extract=20helper,=20use=20Zod=20schemas,=20fi?= =?UTF-8?q?x=20type=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract formatToolOutput() static helper for output transformation - Replace `as any` casts in tool handler with explicit Record - Use Zod schemas for prompt argsSchema (z.string().describe() vs raw objects) - Add zod@^4.3.6 dependency aligned with @objectstack/spec - Document duck-typing pattern for IAIService.toolRegistry access Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/5eb90a65-846f-4f24-947f-1c0e7ea5090e Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../plugins/plugin-mcp-server/package.json | 3 +- .../src/mcp-server-runtime.ts | 50 ++++++++++--------- .../plugins/plugin-mcp-server/src/plugin.ts | 4 ++ pnpm-lock.yaml | 3 ++ 4 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/plugins/plugin-mcp-server/package.json b/packages/plugins/plugin-mcp-server/package.json index 2b3105d3c3..5767e601b4 100644 --- a/packages/plugins/plugin-mcp-server/package.json +++ b/packages/plugins/plugin-mcp-server/package.json @@ -20,7 +20,8 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@objectstack/core": "workspace:*", - "@objectstack/spec": "workspace:*" + "@objectstack/spec": "workspace:*", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^25.5.2", diff --git a/packages/plugins/plugin-mcp-server/src/mcp-server-runtime.ts b/packages/plugins/plugin-mcp-server/src/mcp-server-runtime.ts index eeba2b5358..ff405f7f36 100644 --- a/packages/plugins/plugin-mcp-server/src/mcp-server-runtime.ts +++ b/packages/plugins/plugin-mcp-server/src/mcp-server-runtime.ts @@ -4,7 +4,8 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import type { Logger, IMetadataService, IDataEngine, AIToolDefinition } from '@objectstack/spec/contracts'; import type { Agent } from '@objectstack/spec'; -import type { ToolRegistry } from './types.js'; +import type { ToolRegistry, ToolExecutionResult } from './types.js'; +import { z } from 'zod'; /** * Configuration for the MCP Server Runtime. @@ -91,6 +92,22 @@ export class MCPServerRuntime { return this.started; } + // ── Helpers ───────────────────────────────────────────────────── + + /** + * Extract the text value from a ToolExecutionResult's output. + * + * The output may be a `{ type: 'text', value: string }` object (from the + * Vercel AI SDK ToolResultPart) or any serialisable value. + */ + private static formatToolOutput(result: ToolExecutionResult): string { + const output = result.output; + if (output && typeof output === 'object' && 'value' in output) { + return String((output as { value: unknown }).value); + } + return JSON.stringify(output ?? ''); + } + // ── Tool Bridge ──────────────────────────────────────────────── /** @@ -133,9 +150,10 @@ export class MCPServerRuntime { }, }, async (extra) => { - // MCP SDK passes the raw arguments when no inputSchema is defined - // We need to extract them from the request - const args = (extra as any)?.arguments ?? (extra as any)?.params?.arguments ?? {}; + // The MCP SDK passes tool arguments via the extra.arguments property + // when registerTool is called without an inputSchema. + const rawExtra = extra as Record; + const args = (rawExtra.arguments ?? {}) as Record; try { const result = await toolRegistry.execute({ @@ -145,9 +163,7 @@ export class MCPServerRuntime { input: args, }); - const outputText = result.output && typeof result.output === 'object' && 'value' in result.output - ? String(result.output.value) - : JSON.stringify(result.output ?? ''); + const outputText = MCPServerRuntime.formatToolOutput(result); if (result.isError) { return { @@ -370,22 +386,10 @@ export class MCPServerRuntime { description: 'Load an agent\'s system prompt with optional UI context. ' + 'Use the agentName argument to select which agent\'s instructions to use.', argsSchema: { - agentName: { - type: 'string' as any, - description: 'Name of the agent to load (e.g. "data_chat", "metadata_assistant")', - }, - objectName: { - type: 'string' as any, - description: 'Current object the user is viewing', - }, - recordId: { - type: 'string' as any, - description: 'Currently selected record ID', - }, - viewName: { - type: 'string' as any, - description: 'Current view name', - }, + agentName: z.string().describe('Name of the agent to load (e.g. "data_chat", "metadata_assistant")'), + objectName: z.string().optional().describe('Current object the user is viewing'), + recordId: z.string().optional().describe('Currently selected record ID'), + viewName: z.string().optional().describe('Current view name'), }, }, async (args) => { diff --git a/packages/plugins/plugin-mcp-server/src/plugin.ts b/packages/plugins/plugin-mcp-server/src/plugin.ts index bceef962be..39a6b90bc0 100644 --- a/packages/plugins/plugin-mcp-server/src/plugin.ts +++ b/packages/plugins/plugin-mcp-server/src/plugin.ts @@ -79,6 +79,10 @@ export class MCPServerPlugin implements Plugin { if (!this.runtime) return; // ── Bridge tools from AIService ── + // The IAIService contract does not formally include `toolRegistry` because + // it is an implementation detail of AIService. We use duck-typing here to + // avoid a hard dependency on @objectstack/service-ai while still bridging + // tools when the full AIService implementation is present. try { const aiService = ctx.getService('ai'); if (aiService?.toolRegistry) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e4c84e80c..0afe906c43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -971,6 +971,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../../spec + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@types/node': specifier: ^25.5.2 From f3a08bcc361e23eee6096a0ab8edf8e6f0da47c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:46:10 +0000 Subject: [PATCH 4/4] refactor: extract tool classification sets to module-level constants Moves READ_ONLY_TOOLS and DESTRUCTIVE_TOOLS from inline arrays to module-level Set constants for better maintainability. Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/5eb90a65-846f-4f24-947f-1c0e7ea5090e Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/mcp-server-runtime.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-mcp-server/src/mcp-server-runtime.ts b/packages/plugins/plugin-mcp-server/src/mcp-server-runtime.ts index ff405f7f36..6f37be4a6e 100644 --- a/packages/plugins/plugin-mcp-server/src/mcp-server-runtime.ts +++ b/packages/plugins/plugin-mcp-server/src/mcp-server-runtime.ts @@ -33,6 +33,25 @@ interface ObjectDef { enable?: Record; } +/** + * Names of tools that are read-only (no side effects). + * Kept as a module-level constant for easy extension. + */ +const READ_ONLY_TOOLS = new Set([ + 'list_objects', + 'describe_object', + 'query_records', + 'get_record', + 'aggregate_data', +]); + +/** + * Names of tools that perform destructive mutations. + */ +const DESTRUCTIVE_TOOLS = new Set([ + 'delete_field', +]); + /** * MCPServerRuntime — Bridges ObjectStack kernel services to the Model Context Protocol. * @@ -191,14 +210,14 @@ export class MCPServerRuntime { * Check if a tool is read-only (data query tools). */ private isReadOnlyTool(name: string): boolean { - return ['list_objects', 'describe_object', 'query_records', 'get_record', 'aggregate_data'].includes(name); + return READ_ONLY_TOOLS.has(name); } /** * Check if a tool performs destructive operations. */ private isDestructiveTool(name: string): boolean { - return ['delete_field'].includes(name); + return DESTRUCTIVE_TOOLS.has(name); } // ── Resource Bridge ────────────────────────────────────────────