-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathplugin.ts
More file actions
263 lines (244 loc) · 11.4 KB
/
Copy pathplugin.ts
File metadata and controls
263 lines (244 loc) · 11.4 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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type { Plugin, PluginContext } from '@objectstack/core';
import { resolveAuthzContext } from '@objectstack/core';
import { readEnvWithDeprecation, isMcpServerEnabled, resolveMcpStdioAutoStart } from '@objectstack/types';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { IAIService, 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';
/**
* Resolve `OS_MCP_STDIO_API_KEY` into an {@link ExecutionContext} through the
* SAME `@objectstack/core` verify + authorization chain the HTTP and REST
* surfaces use (`resolveApiKeyPrincipal` → `resolveAuthzContext`), so a stdio
* read is scoped exactly like the same identity over REST (RLS / FLS / tenant).
*
* Fail-closed: returns `undefined` for an unknown / revoked / expired /
* owner-less key (no `userId` resolved). Re-run per read, so revocation of a
* key takes effect on the next call of a live stdio session (ADR-0101 D1).
*/
async function resolveStdioExecutionContext(
ql: { find: (object: string, opts: unknown) => Promise<unknown> },
apiKey: string,
): Promise<ExecutionContext | undefined> {
const authz = await resolveAuthzContext({ ql, headers: { 'x-api-key': apiKey } });
if (!authz.userId) return undefined;
const ec: ExecutionContext = {
positions: authz.positions,
permissions: authz.permissions,
systemPermissions: authz.systemPermissions,
isSystem: false,
principalKind: 'human',
userId: authz.userId,
};
if (authz.tenantId) ec.tenantId = authz.tenantId;
if (authz.email) ec.email = authz.email;
if (authz.posture) ec.posture = authz.posture;
(ec as unknown as { org_user_ids?: string[] }).org_user_ids = authz.org_user_ids;
// [ADR-0105 D2] The caller's org access set — the `group` posture's Layer 0
// wall reads it directly, so every transport must carry it.
(ec as unknown as { accessible_org_ids?: string[] }).accessible_org_ids =
authz.accessible_org_ids;
return ec;
}
/**
* 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';
/**
* Services init() registers on every path (ADR-0116, #4131) — lets the
* kernel name this plugin when a consumer requires one before it inits.
*/
providesServices = ['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');
}
// ── Metadata service for the resource bridge ──
let metadataService: IMetadataService | undefined;
try {
metadataService = ctx.getService<IMetadataService>('metadata');
} catch {
ctx.logger.debug('[MCP] Metadata service not available, skipping resource bridging');
}
// ── stdio auto-start decision (opt-in, its OWN switch) ──
// Deliberately stricter than the HTTP-surface default (`isMcpServerEnabled`,
// default-on): start() attaches a long-lived transport claiming the
// process's stdin/stdout, 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.',
);
}
// ── Principal-bound record reader for the stdio transport (ADR-0101) ──
// The long-lived stdio server reads ROW data only under an env-supplied
// API-key identity, resolved through the same @objectstack/core chain as the
// HTTP/REST surfaces (RLS/FLS/tenant apply). FAIL-CLOSED: stdio auto-start
// without a resolvable key REFUSES to start — no unscoped fallback, and no
// `system`/identity-skipping bypass. Full authority = an admin/service key.
let getRecord:
| ((objectName: string, recordId: string) => Promise<Record<string, unknown> | null>)
| undefined;
if (shouldStart) {
const apiKey = readEnvWithDeprecation('OS_MCP_STDIO_API_KEY', [], { silent: true });
let ql: { find: (object: string, opts: unknown) => Promise<unknown> } | undefined;
try {
ql = ctx.getService('objectql');
} catch {
ql = undefined;
}
if (!apiKey) {
throw new Error(
'[MCP] The stdio transport is enabled (OS_MCP_STDIO_ENABLED / autoStart) but OS_MCP_STDIO_API_KEY is not set. ' +
'stdio must run under a real identity — mint an API key (Setup → Connect an Agent, or POST /api/v1/keys) and set ' +
'OS_MCP_STDIO_API_KEY=osk_.... Refusing to start an unscoped stdio server (ADR-0101).',
);
}
if (!ql || typeof ql.find !== 'function') {
throw new Error(
'[MCP] The stdio transport requires the objectql data service to resolve its principal, but it is not available. ' +
'Refusing to start (ADR-0101).',
);
}
// Validate the key up-front (fail-closed) before attaching the transport.
const initial = await resolveStdioExecutionContext(ql, apiKey);
if (!initial) {
throw new Error(
'[MCP] OS_MCP_STDIO_API_KEY did not resolve to a valid identity (unknown / revoked / expired / owner-less). ' +
'Refusing to start stdio (ADR-0101).',
);
}
const scopedQl = ql;
// Re-resolve per call so a revoked/expired key stops working on the next read.
getRecord = async (objectName, recordId) => {
const ec = await resolveStdioExecutionContext(scopedQl, apiKey);
if (!ec) throw new Error('MCP stdio identity is no longer valid (key revoked or expired)');
const res = (await scopedQl.find(objectName, {
where: { id: recordId },
limit: 1,
context: ec,
})) as unknown;
const rows = res && (res as { value?: unknown }).value ? (res as { value: unknown }).value : res;
const row = Array.isArray(rows) ? rows[0] : rows;
return (row ?? null) as Record<string, unknown> | null;
};
ctx.logger.info(
`[MCP] stdio transport principal-bound to OS_MCP_STDIO_API_KEY identity ${initial.userId} (RLS/FLS/tenant applied)`,
);
}
if (metadataService) {
this.runtime.bridgeResources(metadataService, getRecord);
this.runtime.bridgePrompts(metadataService);
}
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;
}
}