-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathplugin.ts
More file actions
173 lines (155 loc) · 7.14 KB
/
Copy pathplugin.ts
File metadata and controls
173 lines (155 loc) · 7.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type { Plugin, PluginContext } from '@objectstack/core';
import { readEnvWithDeprecation, isMcpServerEnabled, resolveMcpStdioAutoStart } from '@objectstack/types';
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';
import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.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 long-lived transport (stdio) only when
* `autoStart` is enabled or `OS_MCP_SERVER_ENABLED` is explicitly `true` —
* the HTTP surface needs no start: the runtime dispatcher serves it
* per-request at `/api/v1/mcp` (default-on; `OS_MCP_SERVER_ENABLED=false`
* opts out — see `isMcpServerEnabled` in `@objectstack/types`).
* 3. **destroy** — Stops the MCP transport.
*
* Environment Variables:
* - `OS_MCP_SERVER_ENABLED` — HTTP surface default-on; `false` disables it,
* explicit `true` additionally auto-starts the stdio transport
* - `OS_MCP_SERVER_NAME` — Override server name
* - `OS_MCP_SERVER_TRANSPORT` — Override transport ('stdio' | 'http')
* (legacy `MCP_SERVER_*` names still honoured with a deprecation warning)
*
* @example
* ```ts
* import { LiteKernel } from '@objectstack/core';
* import { MCPServerPlugin } from '@objectstack/mcp';
*
* const kernel = new LiteKernel();
* kernel.use(new MCPServerPlugin({ autoStart: true }));
* await kernel.bootstrap();
* ```
*/
export class MCPServerPlugin implements Plugin {
name = 'com.objectstack.mcp';
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<void> {
const config: MCPServerRuntimeConfig = {
name: readEnvWithDeprecation('OS_MCP_SERVER_NAME', 'MCP_SERVER_NAME', { silent: true }) ?? this.options.name ?? 'objectstack',
version: this.options.version ?? '1.0.0',
transport: (readEnvWithDeprecation('OS_MCP_SERVER_TRANSPORT', 'MCP_SERVER_TRANSPORT', { silent: true }) 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<void> {
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<IAIService & { toolRegistry?: ToolRegistry }>('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<IMetadataService>('metadata');
} catch {
ctx.logger.debug('[MCP] Metadata service not available, skipping resource bridging');
}
try {
dataEngine = ctx.getService<IDataEngine>('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 ──
// Deliberately stricter than the HTTP-surface default (`isMcpServerEnabled`,
// default-on): start() attaches a long-lived transport — for stdio that
// means claiming the process's stdin/stdout AND bridging the RAW services
// with no per-request principal (unscoped — see the mcp-stdio-authority
// conformance row) — so it stays opt-in via a SEPARATE switch
// (`OS_MCP_STDIO_ENABLED` / the `autoStart` option), never the HTTP var.
// The HTTP surface does not depend on this: the runtime dispatcher serves
// `/api/v1/mcp` per-request regardless.
const stdio = resolveMcpStdioAutoStart();
const shouldStart = this.options.autoStart || stdio.enabled;
if (stdio.viaDeprecatedAlias && !this.options.autoStart) {
ctx.logger.warn(
'[MCP] Starting the stdio transport via OS_MCP_SERVER_ENABLED=true is DEPRECATED — that var now only gates the default-on HTTP surface. Use OS_MCP_STDIO_ENABLED=true (or the plugin `autoStart` option) for the long-lived stdio transport.',
);
}
if (shouldStart) {
await this.runtime.start();
ctx.logger.info('[MCP] Server started automatically');
} else {
ctx.logger.info(
'[MCP] Transport not auto-started (HTTP is served per-request at /api/v1/mcp regardless). Set OS_MCP_STDIO_ENABLED=true or autoStart for a long-lived (stdio) transport.',
);
}
// ── Plugin-carried Setup UI (cloud ADR-0009 principle) ──
// "Connect an agent" page + nav entry ship WITH the MCP capability and
// follow the HTTP surface's default-on switch: an opted-out deployment
// advertises nothing, so it gets no page either.
if (isMcpServerEnabled()) {
ctx.hook('kernel:ready', async () => {
try {
const manifest = ctx.getService<{ register(m: unknown): void }>('manifest');
manifest?.register?.(CONNECT_AGENT_UI_BUNDLE);
} catch { /* no manifest service (bare kernels, tests) */ }
});
}
// Trigger hook for other plugins to extend MCP
await ctx.trigger('mcp:ready', this.runtime);
}
async destroy(): Promise<void> {
if (this.runtime?.isStarted) {
await this.runtime.stop();
}
this.runtime = undefined;
}
}