@nebutra/mcp 0.1.0 → 0.1.2

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.
@@ -1 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ createContextServerHandlers,
4
+ startContextServer
5
+ } from "../chunk-YMTQFRV2.js";
6
+ export {
7
+ createContextServerHandlers,
8
+ startContextServer
9
+ };
@@ -0,0 +1,14 @@
1
+ import { McpHost } from "../src/index";
2
+
3
+ const host = new McpHost();
4
+
5
+ host.connectLocal({
6
+ id: "read-only",
7
+ name: "Read Only",
8
+ description: "Read-only example tools",
9
+ manifest: { name: "read-only", version: "1.0.0", scopes: ["project:read"] },
10
+ tools: [{ name: "ping", description: "Return pong" }],
11
+ handlers: { ping: async () => "pong" },
12
+ });
13
+
14
+ process.stdout.write(`${JSON.stringify(await host.doctor(), null, 2)}\n`);
@@ -0,0 +1,30 @@
1
+ import { InMemoryToolConsentStore, McpHost } from "../src/index";
2
+
3
+ const consent = new InMemoryToolConsentStore();
4
+ const host = new McpHost({ consent });
5
+
6
+ host.connectLocal({
7
+ id: "notes",
8
+ name: "Notes",
9
+ description: "Tenant-scoped note tools",
10
+ manifest: { name: "notes", version: "1.0.0", scopes: ["notes:write"] },
11
+ tools: [{ name: "create_note", description: "Create a note" }],
12
+ handlers: {
13
+ create_note: async (args) => ({ id: "note_1", title: args.title }),
14
+ },
15
+ });
16
+
17
+ await consent.grant({
18
+ tenantId: "demo",
19
+ serverId: "notes",
20
+ toolName: "create_note",
21
+ scopes: ["notes:write"],
22
+ });
23
+
24
+ const result = await host.callTool(
25
+ "notes:create_note",
26
+ { title: "Layer 1" },
27
+ { requestId: "example_1", tenantId: "demo" },
28
+ );
29
+
30
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -0,0 +1,5 @@
1
+ import { readToolDebug } from "../src/index";
2
+
3
+ const entries = await readToolDebug(5);
4
+
5
+ process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@nebutra/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
- "license": "AGPL-3.0",
5
+ "license": "MIT",
6
6
  "type": "module",
7
7
  "nebutra": {
8
8
  "status": "wip",
@@ -12,11 +12,12 @@
12
12
  ],
13
13
  "gaps": [
14
14
  "No production app integrations",
15
- "Context server binary is a placeholder stub",
16
- "Tool registry + plan-based access control pending"
15
+ "No production MCP transports beyond HTTP/local seams",
16
+ "No durable remote tool discovery or app-level integrations"
17
17
  ],
18
18
  "featureId": "mcp",
19
19
  "category": "ai",
20
+ "surface": "tool-protocol",
20
21
  "summary": "MCP server primitives (Model Context Protocol)"
21
22
  },
22
23
  "main": "./src/index.ts",
@@ -28,11 +29,16 @@
28
29
  "./middleware": "./src/middleware/index.ts"
29
30
  },
30
31
  "bin": {
31
- "nebutra-mcp": "./dist/server/contextServer.js"
32
+ "nebutra-mcp": "./dist/server/contextServer.js",
33
+ "nebutra-tool-protocol": "./dist/cli.js"
32
34
  },
33
35
  "dependencies": {
34
- "@modelcontextprotocol/sdk": "^1.27.1",
35
- "zod": "^4.3.6"
36
+ "@modelcontextprotocol/sdk": "1.29.0",
37
+ "zod": "^4.3.6",
38
+ "@nebutra/capability-kit": "0.2.1",
39
+ "@nebutra/errors": "0.1.1",
40
+ "@nebutra/event-log": "0.1.1",
41
+ "@nebutra/trace-store": "0.1.1"
36
42
  },
37
43
  "devDependencies": {
38
44
  "typescript": "^5.9.3"
@@ -52,6 +58,7 @@
52
58
  "scripts": {
53
59
  "build": "tsup",
54
60
  "dev": "tsup --watch",
61
+ "test": "vitest run",
55
62
  "typecheck": "tsc --noEmit"
56
63
  }
57
64
  }
@@ -0,0 +1,234 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { MCPClient } from "../client/mcpClient";
6
+ import { InMemoryToolConsentStore } from "../consent";
7
+ import { readToolDebug } from "../debug";
8
+ import { McpHost } from "../host";
9
+ import { MCPServerRegistry } from "../registry/serverRegistry";
10
+ import { createContextServerHandlers } from "../server/contextServer";
11
+
12
+ describe("tool protocol host", () => {
13
+ it("requires manifest and per-tool consent before executing a connected server", async () => {
14
+ const consent = new InMemoryToolConsentStore();
15
+ const host = new McpHost({ consent });
16
+ const traces: string[] = [];
17
+
18
+ expect(() =>
19
+ host.connectLocal({
20
+ id: "unsafe",
21
+ name: "Unsafe",
22
+ description: "Missing manifest",
23
+ tools: [],
24
+ handlers: {},
25
+ }),
26
+ ).toThrow(/manifest/i);
27
+
28
+ host.connectLocal({
29
+ id: "notes",
30
+ name: "Notes",
31
+ description: "Tenant-scoped notes tools",
32
+ manifest: {
33
+ name: "notes",
34
+ version: "1.0.0",
35
+ scopes: ["notes:write"],
36
+ },
37
+ tools: [
38
+ {
39
+ name: "create_note",
40
+ description: "Create a note",
41
+ parameters: {},
42
+ },
43
+ ],
44
+ handlers: {
45
+ create_note: async (args, context) => {
46
+ traces.push(`${context.tenantId}:${args.title}`);
47
+ return { id: "note_1" };
48
+ },
49
+ },
50
+ });
51
+
52
+ await expect(
53
+ host.callTool("notes:create_note", { title: "Layer 1" }, { requestId: "r1" }),
54
+ ).resolves.toMatchObject({
55
+ success: false,
56
+ error: expect.stringContaining("tenant"),
57
+ });
58
+
59
+ const context = { requestId: "r2", tenantId: "tenant_a", userId: "user_1" };
60
+ await expect(
61
+ host.callTool("notes:create_note", { title: "Layer 1" }, context),
62
+ ).resolves.toMatchObject({
63
+ success: false,
64
+ error: expect.stringContaining("consent"),
65
+ });
66
+
67
+ await consent.grant({
68
+ tenantId: "tenant_a",
69
+ userId: "user_1",
70
+ serverId: "notes",
71
+ toolName: "create_note",
72
+ scopes: ["notes:write"],
73
+ });
74
+
75
+ await expect(
76
+ host.callTool("notes:create_note", { title: "Layer 1" }, context),
77
+ ).resolves.toMatchObject({
78
+ success: true,
79
+ result: { id: "note_1" },
80
+ });
81
+ expect(traces).toEqual(["tenant_a:Layer 1"]);
82
+ });
83
+
84
+ it("writes protocol debug entries for inspected calls", async () => {
85
+ const consent = new InMemoryToolConsentStore();
86
+ const host = new McpHost({ consent });
87
+ host.connectLocal({
88
+ id: "echo",
89
+ name: "Echo",
90
+ description: "Echo tools",
91
+ manifest: { name: "echo", version: "1.0.0", scopes: ["echo:run"] },
92
+ tools: [{ name: "say", description: "Say a value" }],
93
+ handlers: {
94
+ say: async (args) => args,
95
+ },
96
+ });
97
+ await consent.grant({
98
+ tenantId: "tenant_b",
99
+ serverId: "echo",
100
+ toolName: "say",
101
+ scopes: ["echo:run"],
102
+ });
103
+
104
+ await host.callTool("echo:say", { text: "ok" }, { requestId: "r3", tenantId: "tenant_b" });
105
+
106
+ await expect(readToolDebug(1)).resolves.toEqual([
107
+ expect.objectContaining({ type: "tool_call", serverId: "echo", toolName: "say", ok: true }),
108
+ ]);
109
+ });
110
+ });
111
+
112
+ describe("MCP tool registry access control", () => {
113
+ it("executes registered local tools only when tenant and plan policies pass", async () => {
114
+ const registry = new MCPServerRegistry();
115
+ registry.register({
116
+ id: "tenant-tools",
117
+ name: "Tenant tools",
118
+ description: "Tenant-scoped local tools",
119
+ endpoint: "local://tenant-tools",
120
+ transport: "local",
121
+ allowedTenants: ["tenant_allowed"],
122
+ tools: [
123
+ {
124
+ name: "summarize_context",
125
+ description: "Summarize tenant context",
126
+ parameters: {},
127
+ },
128
+ {
129
+ name: "sync_plan_data",
130
+ description: "Sync plan-gated data",
131
+ parameters: {},
132
+ allowedPlans: ["PRO", "ENTERPRISE"],
133
+ },
134
+ ],
135
+ handlers: {
136
+ summarize_context: async (args, context) => ({
137
+ args,
138
+ tenantId: context.tenantId,
139
+ plan: context.plan,
140
+ }),
141
+ sync_plan_data: async () => ({ synced: true }),
142
+ },
143
+ });
144
+
145
+ const client = new MCPClient(registry);
146
+ const freeContext = {
147
+ requestId: "req_1",
148
+ tenantId: "tenant_allowed",
149
+ plan: "FREE",
150
+ };
151
+
152
+ await expect(
153
+ client.executeTool("summarize_context", { topic: "billing" }, freeContext),
154
+ ).resolves.toMatchObject({
155
+ success: true,
156
+ result: { args: { topic: "billing" }, tenantId: "tenant_allowed", plan: "FREE" },
157
+ });
158
+
159
+ expect(client.listTools(freeContext).map((tool) => tool.name)).toEqual(["summarize_context"]);
160
+
161
+ await expect(client.executeTool("sync_plan_data", {}, freeContext)).resolves.toMatchObject({
162
+ success: false,
163
+ error: "Access denied to tool: sync_plan_data",
164
+ });
165
+
166
+ await expect(
167
+ client.executeTool(
168
+ "sync_plan_data",
169
+ {},
170
+ { requestId: "req_2", tenantId: "tenant_allowed", plan: "PRO" },
171
+ ),
172
+ ).resolves.toMatchObject({
173
+ success: true,
174
+ result: { synced: true },
175
+ });
176
+
177
+ await expect(
178
+ client.executeTool(
179
+ "summarize_context",
180
+ {},
181
+ { requestId: "req_3", tenantId: "tenant_blocked", plan: "ENTERPRISE" },
182
+ ),
183
+ ).resolves.toMatchObject({
184
+ success: false,
185
+ error: "Access denied to tool: summarize_context",
186
+ });
187
+ });
188
+ });
189
+
190
+ describe("context server handlers", () => {
191
+ const tempRoots: string[] = [];
192
+
193
+ afterEach(() => {
194
+ for (const root of tempRoots.splice(0)) {
195
+ fs.rmSync(root, { recursive: true, force: true });
196
+ }
197
+ fs.rmSync(path.join(process.cwd(), ".nebutra"), { recursive: true, force: true });
198
+ });
199
+
200
+ it("lists project resources and returns non-placeholder project structure", async () => {
201
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nebutra-mcp-"));
202
+ tempRoots.push(projectRoot);
203
+ fs.mkdirSync(path.join(projectRoot, "apps", "web", "src", "app", "dashboard"), {
204
+ recursive: true,
205
+ });
206
+ fs.mkdirSync(path.join(projectRoot, "packages", "platform", "db", "prisma"), {
207
+ recursive: true,
208
+ });
209
+ fs.writeFileSync(path.join(projectRoot, "nebutra.config.json"), '{"features":["mcp"]}');
210
+ fs.writeFileSync(
211
+ path.join(projectRoot, "packages", "platform", "db", "prisma", "schema.prisma"),
212
+ "model User { id String @id }",
213
+ );
214
+
215
+ const handlers = createContextServerHandlers(projectRoot);
216
+ const resources = await handlers.listResources();
217
+ expect(resources.resources.map((resource) => resource.uri)).toContain(
218
+ "file:///nebutra.config.json",
219
+ );
220
+
221
+ const structure = await handlers.callTool({
222
+ name: "get_project_structure",
223
+ arguments: {},
224
+ });
225
+ expect(structure.content[0]?.text).toContain("apps/web/src/app/dashboard");
226
+ expect(structure.content[0]?.text).not.toContain("Apps dir not found");
227
+
228
+ await expect(
229
+ handlers.readResource({ uri: "file:///prisma/schema.prisma" }),
230
+ ).resolves.toMatchObject({
231
+ contents: [{ text: "model User { id String @id }" }],
232
+ });
233
+ });
234
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { McpHost, readToolDebug } from "./index";
2
+ import { createContextServerHandlers } from "./server/contextServer";
3
+
4
+ const command = process.argv[2] ?? "doctor";
5
+ const host = new McpHost();
6
+ const contextHandlers = createContextServerHandlers(process.cwd());
7
+
8
+ host.connectLocal({
9
+ id: "nebutra-context",
10
+ name: "Nebutra Context",
11
+ description: "Read-only project context tools",
12
+ manifest: {
13
+ name: "nebutra-context",
14
+ version: "1.0.0",
15
+ scopes: ["project:read"],
16
+ },
17
+ tools: [
18
+ {
19
+ name: "get_project_structure",
20
+ description: "Returns a bounded app/package routing tree for the current project.",
21
+ parameters: {},
22
+ },
23
+ ],
24
+ handlers: {
25
+ get_project_structure: async () => contextHandlers.callTool({ name: "get_project_structure" }),
26
+ },
27
+ });
28
+
29
+ if (command === "doctor") {
30
+ process.stdout.write(
31
+ `${JSON.stringify({ capability: "tool-protocol", ...(await host.doctor()) }, null, 2)}\n`,
32
+ );
33
+ } else if (command === "debug") {
34
+ const id = process.argv[3];
35
+ const entries = await readToolDebug(id ? 50 : 10);
36
+ process.stdout.write(
37
+ `${JSON.stringify({ capability: "tool-protocol", id, entries }, null, 2)}\n`,
38
+ );
39
+ } else if (command === "inspect") {
40
+ const serverId = process.argv[3] ?? "nebutra-context";
41
+ process.stdout.write(
42
+ `${JSON.stringify({ capability: "tool-protocol", server: host.inspect(serverId) }, null, 2)}\n`,
43
+ );
44
+ } else {
45
+ process.stderr.write(`Unknown tool-protocol command: ${command}\n`);
46
+ process.exitCode = 1;
47
+ }
@@ -1,3 +1,4 @@
1
+ import type { MCPServerRegistry } from "../registry/serverRegistry";
1
2
  import { serverRegistry } from "../registry/serverRegistry";
2
3
  import type { MCPContext, MCPServerConfig, ToolExecutionResult } from "../types";
3
4
 
@@ -7,6 +8,8 @@ import type { MCPContext, MCPServerConfig, ToolExecutionResult } from "../types"
7
8
  export class MCPClient {
8
9
  private requestCounter = 0;
9
10
 
11
+ constructor(private readonly registry: MCPServerRegistry = serverRegistry) {}
12
+
10
13
  /**
11
14
  * Execute a tool by name
12
15
  */
@@ -18,7 +21,7 @@ export class MCPClient {
18
21
  const startTime = Date.now();
19
22
 
20
23
  // Find server for this tool
21
- const server = serverRegistry.findServerByTool(toolName);
24
+ const server = this.registry.findServerByTool(toolName);
22
25
  if (!server) {
23
26
  return {
24
27
  success: false,
@@ -28,7 +31,7 @@ export class MCPClient {
28
31
  }
29
32
 
30
33
  // Check access
31
- if (!serverRegistry.canAccess(server.id, context)) {
34
+ if (!this.registry.canAccessTool(toolName, context)) {
32
35
  return {
33
36
  success: false,
34
37
  error: `Access denied to tool: ${toolName}`,
@@ -69,6 +72,8 @@ export class MCPClient {
69
72
  return this.executeHttp(server, toolName, args, requestId);
70
73
  case "websocket":
71
74
  return this.executeWebSocket(server, toolName, args, requestId);
75
+ case "local":
76
+ return this.executeLocal(server, toolName, args, context);
72
77
  case "stdio":
73
78
  throw new Error("stdio transport not supported in browser/serverless");
74
79
  default:
@@ -146,21 +151,37 @@ export class MCPClient {
146
151
  throw new Error("WebSocket transport not yet implemented");
147
152
  }
148
153
 
154
+ private async executeLocal(
155
+ server: MCPServerConfig,
156
+ toolName: string,
157
+ args: Record<string, unknown>,
158
+ context: MCPContext,
159
+ ): Promise<unknown> {
160
+ const shortName = toolName.startsWith(`${server.id}:`)
161
+ ? toolName.slice(server.id.length + 1)
162
+ : toolName;
163
+ const handler = server.handlers?.[shortName] ?? server.handlers?.[toolName];
164
+ if (!handler) {
165
+ throw new Error(`No local handler registered for tool: ${toolName}`);
166
+ }
167
+ return handler(args, context);
168
+ }
169
+
149
170
  /**
150
171
  * List available tools for a context
151
172
  */
152
173
  listTools(context: MCPContext) {
153
- return serverRegistry.getAccessibleTools(context);
174
+ return this.registry.getAccessibleTools(context);
154
175
  }
155
176
 
156
177
  /**
157
178
  * Get tool definition
158
179
  */
159
180
  getTool(toolName: string, context: MCPContext) {
160
- const server = serverRegistry.findServerByTool(toolName);
181
+ const server = this.registry.findServerByTool(toolName);
161
182
  if (!server) return undefined;
162
183
 
163
- if (!serverRegistry.canAccess(server.id, context)) {
184
+ if (!this.registry.canAccessTool(toolName, context)) {
164
185
  return undefined;
165
186
  }
166
187
 
package/src/consent.ts ADDED
@@ -0,0 +1,46 @@
1
+ export interface ToolConsentGrant {
2
+ readonly tenantId: string;
3
+ readonly userId?: string;
4
+ readonly serverId: string;
5
+ readonly toolName: string;
6
+ readonly scopes: readonly string[];
7
+ }
8
+
9
+ export interface ToolConsentStore {
10
+ grant(grant: ToolConsentGrant): Promise<void>;
11
+ hasConsent(grant: ToolConsentGrant): Promise<boolean>;
12
+ list(tenantId?: string): Promise<ToolConsentGrant[]>;
13
+ }
14
+
15
+ function consentKey(grant: Omit<ToolConsentGrant, "scopes">): string {
16
+ return [grant.tenantId, grant.userId ?? "*", grant.serverId, grant.toolName].join(":");
17
+ }
18
+
19
+ export class InMemoryToolConsentStore implements ToolConsentStore {
20
+ readonly #grants = new Map<string, ToolConsentGrant>();
21
+
22
+ async grant(grant: ToolConsentGrant): Promise<void> {
23
+ this.#grants.set(consentKey(grant), { ...grant, scopes: [...grant.scopes] });
24
+ }
25
+
26
+ async hasConsent(grant: ToolConsentGrant): Promise<boolean> {
27
+ const exact = this.#grants.get(consentKey(grant));
28
+ const wildcard = this.#grants.get(
29
+ consentKey({
30
+ tenantId: grant.tenantId,
31
+ serverId: grant.serverId,
32
+ toolName: grant.toolName,
33
+ }),
34
+ );
35
+ const existing = exact ?? wildcard;
36
+ if (!existing) return false;
37
+ const scopes = new Set(existing.scopes);
38
+ return grant.scopes.every((scope) => scopes.has(scope));
39
+ }
40
+
41
+ async list(tenantId?: string): Promise<ToolConsentGrant[]> {
42
+ return Array.from(this.#grants.values()).filter(
43
+ (grant) => tenantId === undefined || grant.tenantId === tenantId,
44
+ );
45
+ }
46
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,32 @@
1
+ import {
2
+ appendCapabilityDebug,
3
+ capabilityDebugPath,
4
+ readCapabilityDebug,
5
+ } from "@nebutra/capability-kit/debug";
6
+
7
+ export interface ToolDebugEntry {
8
+ readonly at: string;
9
+ readonly type: "tool_call" | "connect" | "inspect";
10
+ readonly requestId?: string;
11
+ readonly tenantId?: string;
12
+ readonly serverId?: string;
13
+ readonly toolName?: string;
14
+ readonly ok: boolean;
15
+ readonly durationMs?: number;
16
+ readonly error?: string;
17
+ readonly suggestion?: string;
18
+ }
19
+
20
+ export function toolDebugPath(): string {
21
+ return capabilityDebugPath("tool-protocol");
22
+ }
23
+
24
+ export async function appendToolDebug(
25
+ entry: Omit<ToolDebugEntry, "at"> & { at?: string },
26
+ ): Promise<void> {
27
+ await appendCapabilityDebug("tool-protocol", entry);
28
+ }
29
+
30
+ export async function readToolDebug(limit = 10): Promise<ToolDebugEntry[]> {
31
+ return readCapabilityDebug("tool-protocol", { limit }) as Promise<ToolDebugEntry[]>;
32
+ }