@nebutra/mcp 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/host.ts DELETED
@@ -1,314 +0,0 @@
1
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
- import { CapabilityError } from "@nebutra/errors";
5
- import type { EventLog } from "@nebutra/event-log";
6
- import type { TraceStore } from "@nebutra/trace-store";
7
- import { MCPClient } from "./client/mcpClient";
8
- import { InMemoryToolConsentStore, type ToolConsentStore } from "./consent";
9
- import { appendToolDebug, readToolDebug } from "./debug";
10
- import { MCPServerRegistry } from "./registry/serverRegistry";
11
- import type {
12
- MCPContext,
13
- MCPServerConfig,
14
- MCPServerManifest,
15
- MCPToolHandler,
16
- ToolDefinition,
17
- ToolExecutionResult,
18
- } from "./types";
19
-
20
- export interface McpHostOptions {
21
- readonly registry?: MCPServerRegistry;
22
- readonly consent?: ToolConsentStore;
23
- readonly traceStore?: TraceStore;
24
- readonly eventLog?: EventLog;
25
- }
26
-
27
- export interface LocalMcpServerConfig {
28
- readonly id: string;
29
- readonly name: string;
30
- readonly description: string;
31
- readonly manifest?: MCPServerManifest;
32
- readonly tools: readonly ToolDefinition[];
33
- readonly handlers: Record<string, MCPToolHandler>;
34
- readonly allowedPlans?: readonly string[];
35
- readonly allowedTenants?: readonly string[];
36
- }
37
-
38
- export interface RemoteMcpConnectOptions {
39
- readonly id: string;
40
- readonly name?: string;
41
- readonly description?: string;
42
- readonly manifest: MCPServerManifest;
43
- readonly headers?: Record<string, string>;
44
- }
45
-
46
- function shortToolName(serverId: string, toolName: string): string {
47
- return toolName.startsWith(`${serverId}:`) ? toolName.slice(serverId.length + 1) : toolName;
48
- }
49
-
50
- function requireManifest(
51
- serverId: string,
52
- manifest: MCPServerManifest | undefined,
53
- ): MCPServerManifest {
54
- if (!manifest || manifest.scopes.length === 0 || manifest.version.length === 0) {
55
- throw new CapabilityError("tool-protocol", "MCP server manifest is required", {
56
- suggestion: "Register every connected server with name, version, and declared scopes.",
57
- metadata: { serverId },
58
- statusCode: 400,
59
- });
60
- }
61
- return manifest;
62
- }
63
-
64
- function toJsonObject(value: unknown): Record<string, unknown> {
65
- return value && typeof value === "object" && !Array.isArray(value)
66
- ? (value as Record<string, unknown>)
67
- : { value };
68
- }
69
-
70
- export class McpHost {
71
- readonly #registry: MCPServerRegistry;
72
- readonly #client: MCPClient;
73
- readonly #consent: ToolConsentStore;
74
- readonly #traceStore: TraceStore | undefined;
75
- readonly #eventLog: EventLog | undefined;
76
- readonly #sdkClients = new Map<string, Client>();
77
-
78
- constructor(options: McpHostOptions = {}) {
79
- this.#registry = options.registry ?? new MCPServerRegistry();
80
- this.#client = new MCPClient(this.#registry);
81
- this.#consent = options.consent ?? new InMemoryToolConsentStore();
82
- this.#traceStore = options.traceStore;
83
- this.#eventLog = options.eventLog;
84
- }
85
-
86
- get registry(): MCPServerRegistry {
87
- return this.#registry;
88
- }
89
-
90
- connectLocal(config: LocalMcpServerConfig): void {
91
- const manifest = requireManifest(config.id, config.manifest);
92
- this.#registry.register({
93
- id: config.id,
94
- name: config.name,
95
- description: config.description,
96
- endpoint: `local://${config.id}`,
97
- transport: "local",
98
- manifest,
99
- tools: [...config.tools],
100
- handlers: config.handlers,
101
- ...(config.allowedPlans !== undefined && { allowedPlans: [...config.allowedPlans] }),
102
- ...(config.allowedTenants !== undefined && { allowedTenants: [...config.allowedTenants] }),
103
- });
104
- }
105
-
106
- async connectStdio(
107
- command: string,
108
- args: readonly string[],
109
- options: RemoteMcpConnectOptions,
110
- ): Promise<void> {
111
- const transport = new StdioClientTransport({ command, args: [...args] });
112
- await this.#connectSdkClient(options, transport, `stdio://${command}`);
113
- }
114
-
115
- async connectStreamableHttp(endpoint: string, options: RemoteMcpConnectOptions): Promise<void> {
116
- const transport = new StreamableHTTPClientTransport(
117
- new URL(endpoint),
118
- options.headers !== undefined ? { requestInit: { headers: options.headers } } : {},
119
- );
120
- await this.#connectSdkClient(options, transport, endpoint);
121
- }
122
-
123
- async callTool(
124
- toolName: string,
125
- args: Record<string, unknown>,
126
- context: MCPContext,
127
- ): Promise<ToolExecutionResult> {
128
- const start = Date.now();
129
- const server = this.#registry.findServerByTool(toolName);
130
- const serverId = server?.id;
131
- const shortName = serverId ? shortToolName(serverId, toolName) : toolName;
132
- const span = this.#traceStore?.start("tool", toolName, {
133
- traceId: context.requestId,
134
- tenantId: context.tenantId,
135
- serverId,
136
- });
137
-
138
- if (!context.tenantId) {
139
- const result = {
140
- success: false,
141
- error: "Missing tenant context; suggestion: call tools with a tenantId.",
142
- duration: Date.now() - start,
143
- };
144
- await this.#recordCall(toolName, shortName, serverId, context, result);
145
- span?.fail(result.error);
146
- return result;
147
- }
148
-
149
- if (!server || !server.manifest) {
150
- const result = {
151
- success: false,
152
- error: "Tool server is not connected with a manifest; suggestion: run `pnpm tool:doctor`.",
153
- duration: Date.now() - start,
154
- };
155
- await this.#recordCall(toolName, shortName, serverId, context, result);
156
- span?.fail(result.error);
157
- return result;
158
- }
159
-
160
- const consentRequest = {
161
- tenantId: context.tenantId,
162
- serverId: server.id,
163
- toolName: shortName,
164
- scopes: server.manifest.scopes,
165
- ...(context.userId !== undefined && { userId: context.userId }),
166
- };
167
- const hasConsent = await this.#consent.hasConsent(consentRequest);
168
-
169
- if (!hasConsent) {
170
- const result = {
171
- success: false,
172
- error: `Missing per-tool consent for ${server.id}:${shortName}; suggestion: grant scopes ${server.manifest.scopes.join(", ")}.`,
173
- duration: Date.now() - start,
174
- };
175
- await this.#recordCall(toolName, shortName, server.id, context, result);
176
- span?.fail(result.error);
177
- return result;
178
- }
179
-
180
- const result = await this.#client.executeTool(toolName, args, context);
181
- await this.#recordCall(toolName, shortName, server.id, context, result);
182
- if (result.success) span?.end({ toolName });
183
- else span?.fail(result.error);
184
- return result;
185
- }
186
-
187
- listTools(context: MCPContext): Array<ToolDefinition & { serverId: string }> {
188
- return this.#client.listTools(context);
189
- }
190
-
191
- inspect(serverId: string): MCPServerConfig {
192
- const server = this.#registry.getServer(serverId);
193
- if (!server) {
194
- throw new CapabilityError("tool-protocol", "MCP server not found", {
195
- suggestion: "Run `pnpm tool:doctor` to list connected servers.",
196
- metadata: { serverId },
197
- statusCode: 404,
198
- });
199
- }
200
- void appendToolDebug({ type: "inspect", serverId, ok: true });
201
- return server;
202
- }
203
-
204
- async doctor(): Promise<{
205
- ok: boolean;
206
- servers: Array<{
207
- id: string;
208
- name: string;
209
- transport: MCPServerConfig["transport"];
210
- tools: number;
211
- manifest?: MCPServerManifest;
212
- }>;
213
- suggestion?: string;
214
- }> {
215
- const servers = this.#registry.getAllServers().map((server) => ({
216
- id: server.id,
217
- name: server.name,
218
- transport: server.transport,
219
- tools: server.tools.length,
220
- ...(server.manifest !== undefined && { manifest: server.manifest }),
221
- }));
222
- return {
223
- ok: servers.length > 0,
224
- servers,
225
- ...(servers.length === 0 && {
226
- suggestion: "Connect an MCP server or register a local tool surface before running agents.",
227
- }),
228
- };
229
- }
230
-
231
- async debug(limit = 10) {
232
- return readToolDebug(limit);
233
- }
234
-
235
- async close(): Promise<void> {
236
- await Promise.all(Array.from(this.#sdkClients.values()).map((client) => client.close()));
237
- this.#sdkClients.clear();
238
- }
239
-
240
- async #connectSdkClient(
241
- options: RemoteMcpConnectOptions,
242
- transport: unknown,
243
- endpoint: string,
244
- ): Promise<void> {
245
- const manifest = requireManifest(options.id, options.manifest);
246
- const client = new Client({ name: "nebutra-tool-protocol", version: "0.1.0" });
247
- await client.connect(transport as Parameters<Client["connect"]>[0]);
248
- const listed = await client.listTools();
249
- const tools = listed.tools.map(
250
- (tool) =>
251
- ({
252
- name: tool.name,
253
- description: tool.description ?? tool.name,
254
- parameters: {},
255
- }) satisfies ToolDefinition,
256
- );
257
- const handlers = Object.fromEntries(
258
- tools.map((tool) => [
259
- tool.name,
260
- async (args: Record<string, unknown>) => {
261
- return client.callTool({ name: tool.name, arguments: toJsonObject(args) });
262
- },
263
- ]),
264
- );
265
- this.#sdkClients.set(options.id, client);
266
- this.#registry.register({
267
- id: options.id,
268
- name: options.name ?? manifest.name,
269
- description: options.description ?? `${manifest.name} MCP server`,
270
- endpoint,
271
- transport: endpoint.startsWith("stdio://") ? "stdio" : "http",
272
- manifest,
273
- tools,
274
- handlers,
275
- });
276
- await appendToolDebug({ type: "connect", serverId: options.id, ok: true });
277
- }
278
-
279
- async #recordCall(
280
- originalToolName: string,
281
- toolName: string,
282
- serverId: string | undefined,
283
- context: MCPContext,
284
- result: ToolExecutionResult,
285
- ): Promise<void> {
286
- await appendToolDebug({
287
- type: "tool_call",
288
- requestId: context.requestId,
289
- toolName,
290
- ok: result.success,
291
- durationMs: result.duration,
292
- ...(context.tenantId !== undefined && { tenantId: context.tenantId }),
293
- ...(serverId !== undefined && { serverId }),
294
- ...(result.error !== undefined && { error: result.error }),
295
- });
296
- if (result.success && context.tenantId && this.#eventLog) {
297
- await this.#eventLog.commit({
298
- traceId: context.requestId,
299
- kind: "tool_call",
300
- affected: [],
301
- parent: null,
302
- snapshot: {
303
- [`tool/${serverId ?? "unknown"}/${toolName}.json`]: JSON.stringify({
304
- toolName: originalToolName,
305
- result: result.result,
306
- }),
307
- },
308
- });
309
- }
310
- }
311
- }
312
-
313
- export const toolConsentStore = new InMemoryToolConsentStore();
314
- export const mcpHost = new McpHost({ consent: toolConsentStore });
package/src/index.ts DELETED
@@ -1,30 +0,0 @@
1
- // Types
2
-
3
- // Client
4
- export { MCPClient, mcpClient } from "./client/index";
5
- export {
6
- InMemoryToolConsentStore,
7
- type ToolConsentGrant,
8
- type ToolConsentStore,
9
- } from "./consent";
10
- export { appendToolDebug, readToolDebug, type ToolDebugEntry, toolDebugPath } from "./debug";
11
- export { McpHost, mcpHost, toolConsentStore } from "./host";
12
- // Middleware
13
- export {
14
- type AuditLogEntry,
15
- composeMCPMiddleware,
16
- createAccessControlMiddleware,
17
- createAuditMiddleware,
18
- createRateLimitMiddleware,
19
- type MCPMiddleware,
20
- type MCPMiddlewareContext,
21
- } from "./middleware/index";
22
- // Registry
23
- export { MCPServerRegistry, serverRegistry } from "./registry/index";
24
- // Server
25
- export {
26
- getInternalServerIds,
27
- INTERNAL_SERVERS,
28
- registerInternalServers,
29
- } from "./server/index";
30
- export * from "./types";
@@ -1,10 +0,0 @@
1
- export {
2
- type AuditLogEntry,
3
- composeMCPMiddleware,
4
- createAccessControlMiddleware,
5
- createAuditMiddleware,
6
- createRateLimitMiddleware,
7
- type MCPMiddleware,
8
- type MCPMiddlewareContext,
9
- type MCPMiddlewareNext,
10
- } from "./mcpMiddleware";
@@ -1,150 +0,0 @@
1
- import type { MCPContext, ToolExecutionResult } from "../types";
2
-
3
- /**
4
- * MCP Middleware for rate limiting, audit logging, and access control
5
- */
6
-
7
- export interface MCPMiddlewareContext extends MCPContext {
8
- toolName: string;
9
- arguments: Record<string, unknown>;
10
- }
11
-
12
- export type MCPMiddlewareNext = () => Promise<ToolExecutionResult>;
13
-
14
- export type MCPMiddleware = (
15
- ctx: MCPMiddlewareContext,
16
- next: MCPMiddlewareNext,
17
- ) => Promise<ToolExecutionResult>;
18
-
19
- /**
20
- * Rate limiting middleware for MCP calls
21
- */
22
- export function createRateLimitMiddleware(options: {
23
- maxRequests: number;
24
- windowMs: number;
25
- }): MCPMiddleware {
26
- const requests = new Map<string, { count: number; resetAt: number }>();
27
-
28
- return async (ctx, next) => {
29
- const key = `${ctx.tenantId || "anon"}:${ctx.userId || "anon"}`;
30
- const now = Date.now();
31
-
32
- let record = requests.get(key);
33
- if (!record || now > record.resetAt) {
34
- record = { count: 0, resetAt: now + options.windowMs };
35
- requests.set(key, record);
36
- }
37
-
38
- record.count++;
39
-
40
- if (record.count > options.maxRequests) {
41
- return {
42
- success: false,
43
- error: `MCP rate limit exceeded. Max ${options.maxRequests} requests per ${options.windowMs / 1000}s`,
44
- duration: 0,
45
- };
46
- }
47
-
48
- return next();
49
- };
50
- }
51
-
52
- /**
53
- * Audit logging middleware
54
- */
55
- export function createAuditMiddleware(options: {
56
- onLog: (entry: AuditLogEntry) => void | Promise<void>;
57
- }): MCPMiddleware {
58
- return async (ctx, next) => {
59
- const result = await next();
60
-
61
- const entry: AuditLogEntry = {
62
- timestamp: new Date().toISOString(),
63
- requestId: ctx.requestId,
64
- toolName: ctx.toolName,
65
- arguments: ctx.arguments,
66
- success: result.success,
67
- duration: result.duration,
68
- };
69
-
70
- if (ctx.tenantId) entry.tenantId = ctx.tenantId;
71
- if (ctx.userId) entry.userId = ctx.userId;
72
- if (result.error) entry.error = result.error;
73
-
74
- // Don't block on audit logging
75
- Promise.resolve(options.onLog(entry)).catch(console.error);
76
-
77
- return result;
78
- };
79
- }
80
-
81
- export interface AuditLogEntry {
82
- timestamp: string;
83
- requestId: string;
84
- tenantId?: string;
85
- userId?: string;
86
- toolName: string;
87
- arguments: Record<string, unknown>;
88
- success: boolean;
89
- duration: number;
90
- error?: string;
91
- }
92
-
93
- /**
94
- * Tool access control middleware
95
- */
96
- export function createAccessControlMiddleware(options: {
97
- blockedTools?: string[];
98
- allowedTools?: string[];
99
- }): MCPMiddleware {
100
- return async (ctx, next) => {
101
- // Check blocked list
102
- if (options.blockedTools?.includes(ctx.toolName)) {
103
- return {
104
- success: false,
105
- error: `Tool ${ctx.toolName} is blocked`,
106
- duration: 0,
107
- };
108
- }
109
-
110
- // Check allowed list (if specified)
111
- if (
112
- options.allowedTools &&
113
- options.allowedTools.length > 0 &&
114
- !options.allowedTools.includes(ctx.toolName)
115
- ) {
116
- return {
117
- success: false,
118
- error: `Tool ${ctx.toolName} is not in allowed list`,
119
- duration: 0,
120
- };
121
- }
122
-
123
- return next();
124
- };
125
- }
126
-
127
- /**
128
- * Compose multiple middlewares
129
- */
130
- export function composeMCPMiddleware(middlewares: MCPMiddleware[]): MCPMiddleware {
131
- return async (ctx, finalNext) => {
132
- let index = -1;
133
-
134
- const dispatch = async (i: number): Promise<ToolExecutionResult> => {
135
- if (i <= index) {
136
- throw new Error("next() called multiple times");
137
- }
138
- index = i;
139
-
140
- const middleware = middlewares[i];
141
- if (!middleware) {
142
- return finalNext();
143
- }
144
-
145
- return middleware(ctx, () => dispatch(i + 1));
146
- };
147
-
148
- return dispatch(0);
149
- };
150
- }
@@ -1 +0,0 @@
1
- export { MCPServerRegistry, serverRegistry } from "./serverRegistry";
@@ -1,165 +0,0 @@
1
- import type { MCPContext, MCPServerConfig, ToolDefinition } from "../types";
2
-
3
- /**
4
- * Registry for MCP servers and their tools
5
- * Handles server registration, discovery, and access control
6
- */
7
- export class MCPServerRegistry {
8
- private servers: Map<string, MCPServerConfig> = new Map();
9
- private toolIndex: Map<string, string> = new Map(); // tool name -> server id
10
-
11
- /**
12
- * Register an MCP server
13
- */
14
- register(config: MCPServerConfig): void {
15
- this.servers.set(config.id, config);
16
-
17
- // Index tools for quick lookup
18
- for (const tool of config.tools) {
19
- const qualifiedName = `${config.id}:${tool.name}`;
20
- this.toolIndex.set(qualifiedName, config.id);
21
- // Also allow short name if unique
22
- if (!this.toolIndex.has(tool.name)) {
23
- this.toolIndex.set(tool.name, config.id);
24
- }
25
- }
26
- }
27
-
28
- /**
29
- * Unregister an MCP server
30
- */
31
- unregister(serverId: string): boolean {
32
- const server = this.servers.get(serverId);
33
- if (!server) return false;
34
-
35
- // Remove tool index entries
36
- for (const tool of server.tools) {
37
- const qualifiedName = `${serverId}:${tool.name}`;
38
- this.toolIndex.delete(qualifiedName);
39
- // Only remove short name if it points to this server
40
- if (this.toolIndex.get(tool.name) === serverId) {
41
- this.toolIndex.delete(tool.name);
42
- }
43
- }
44
-
45
- this.servers.delete(serverId);
46
- return true;
47
- }
48
-
49
- /**
50
- * Get server by ID
51
- */
52
- getServer(serverId: string): MCPServerConfig | undefined {
53
- return this.servers.get(serverId);
54
- }
55
-
56
- /**
57
- * Find server by tool name
58
- */
59
- findServerByTool(toolName: string): MCPServerConfig | undefined {
60
- const serverId = this.toolIndex.get(toolName);
61
- if (!serverId) return undefined;
62
- return this.servers.get(serverId);
63
- }
64
-
65
- /**
66
- * Get all registered servers
67
- */
68
- getAllServers(): MCPServerConfig[] {
69
- return Array.from(this.servers.values());
70
- }
71
-
72
- /**
73
- * Get all available tools
74
- */
75
- getAllTools(): Array<ToolDefinition & { serverId: string }> {
76
- const tools: Array<ToolDefinition & { serverId: string }> = [];
77
- for (const server of this.servers.values()) {
78
- for (const tool of server.tools) {
79
- tools.push({ ...tool, serverId: server.id });
80
- }
81
- }
82
- return tools;
83
- }
84
-
85
- /**
86
- * Check if a context has access to a server
87
- */
88
- canAccess(serverId: string, context: MCPContext): boolean {
89
- const server = this.servers.get(serverId);
90
- if (!server) return false;
91
-
92
- // Check plan restrictions
93
- if (server.allowedPlans && server.allowedPlans.length > 0) {
94
- if (!context.plan || !server.allowedPlans.includes(context.plan)) {
95
- return false;
96
- }
97
- }
98
-
99
- // Check tenant restrictions
100
- if (server.allowedTenants && server.allowedTenants.length > 0) {
101
- if (!context.tenantId || !server.allowedTenants.includes(context.tenantId)) {
102
- return false;
103
- }
104
- }
105
-
106
- return true;
107
- }
108
-
109
- /**
110
- * Check whether a context can execute a specific tool. Tool-level policy
111
- * narrows the server policy and supports tenant, plan, and permission gates.
112
- */
113
- canAccessTool(toolName: string, context: MCPContext): boolean {
114
- const server = this.findServerByTool(toolName);
115
- if (!server || !this.canAccess(server.id, context)) return false;
116
-
117
- const tool = this.findTool(server, toolName);
118
- if (!tool) return false;
119
-
120
- if (tool.allowedPlans && tool.allowedPlans.length > 0) {
121
- if (!context.plan || !tool.allowedPlans.includes(context.plan)) return false;
122
- }
123
-
124
- if (tool.allowedTenants && tool.allowedTenants.length > 0) {
125
- if (!context.tenantId || !tool.allowedTenants.includes(context.tenantId)) return false;
126
- }
127
-
128
- if (tool.requiredPermissions && tool.requiredPermissions.length > 0) {
129
- const permissions = new Set(context.permissions ?? []);
130
- if (!tool.requiredPermissions.every((permission) => permissions.has(permission))) {
131
- return false;
132
- }
133
- }
134
-
135
- return true;
136
- }
137
-
138
- /**
139
- * Get tools accessible by a context
140
- */
141
- getAccessibleTools(context: MCPContext): Array<ToolDefinition & { serverId: string }> {
142
- const tools: Array<ToolDefinition & { serverId: string }> = [];
143
-
144
- for (const server of this.servers.values()) {
145
- if (this.canAccess(server.id, context)) {
146
- for (const tool of server.tools) {
147
- if (this.canAccessTool(`${server.id}:${tool.name}`, context)) {
148
- tools.push({ ...tool, serverId: server.id });
149
- }
150
- }
151
- }
152
- }
153
-
154
- return tools;
155
- }
156
-
157
- private findTool(server: MCPServerConfig, toolName: string): ToolDefinition | undefined {
158
- return server.tools.find(
159
- (tool) => tool.name === toolName || `${server.id}:${tool.name}` === toolName,
160
- );
161
- }
162
- }
163
-
164
- // Global registry instance
165
- export const serverRegistry = new MCPServerRegistry();