@nebutra/mcp 0.1.0 → 0.1.1
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/.turbo/turbo-build.log +22 -0
- package/.turbo/turbo-test.log +14 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +15 -0
- package/dist/chunk-ORGYGQJV.js +711 -0
- package/dist/chunk-YMTQFRV2.js +178 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +55 -0
- package/dist/index.d.ts +378 -0
- package/dist/index.js +44 -0
- package/dist/server/contextServer.d.ts +50 -0
- package/dist/server/contextServer.js +8 -0
- package/examples/doctor.ts +14 -0
- package/examples/local-consent.ts +30 -0
- package/examples/read-debug.ts +5 -0
- package/package.json +12 -7
- package/src/__tests__/registry-and-context.test.ts +234 -0
- package/src/cli.ts +47 -0
- package/src/client/mcpClient.ts +26 -5
- package/src/consent.ts +46 -0
- package/src/debug.ts +43 -0
- package/src/host.ts +314 -0
- package/src/index.ts +7 -0
- package/src/registry/serverRegistry.ts +38 -1
- package/src/server/contextServer.ts +130 -57
- package/src/types.ts +18 -1
- package/tsup.config.ts +7 -1
|
@@ -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`);
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebutra/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"private": false,
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"nebutra": {
|
|
8
8
|
"status": "wip",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
],
|
|
13
13
|
"gaps": [
|
|
14
14
|
"No production app integrations",
|
|
15
|
-
"
|
|
16
|
-
"
|
|
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",
|
|
@@ -28,11 +28,15 @@
|
|
|
28
28
|
"./middleware": "./src/middleware/index.ts"
|
|
29
29
|
},
|
|
30
30
|
"bin": {
|
|
31
|
-
"nebutra-mcp": "./dist/server/contextServer.js"
|
|
31
|
+
"nebutra-mcp": "./dist/server/contextServer.js",
|
|
32
|
+
"nebutra-tool-protocol": "./dist/cli.js"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@modelcontextprotocol/sdk": "
|
|
35
|
-
"zod": "^4.3.6"
|
|
35
|
+
"@modelcontextprotocol/sdk": "1.27.1",
|
|
36
|
+
"zod": "^4.3.6",
|
|
37
|
+
"@nebutra/errors": "0.1.0",
|
|
38
|
+
"@nebutra/event-log": "0.1.0",
|
|
39
|
+
"@nebutra/trace-store": "0.1.0"
|
|
36
40
|
},
|
|
37
41
|
"devDependencies": {
|
|
38
42
|
"typescript": "^5.9.3"
|
|
@@ -52,6 +56,7 @@
|
|
|
52
56
|
"scripts": {
|
|
53
57
|
"build": "tsup",
|
|
54
58
|
"dev": "tsup --watch",
|
|
59
|
+
"test": "vitest run",
|
|
55
60
|
"typecheck": "tsc --noEmit"
|
|
56
61
|
}
|
|
57
62
|
}
|
|
@@ -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
|
+
}
|
package/src/client/mcpClient.ts
CHANGED
|
@@ -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 =
|
|
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 (!
|
|
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
|
|
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 =
|
|
181
|
+
const server = this.registry.findServerByTool(toolName);
|
|
161
182
|
if (!server) return undefined;
|
|
162
183
|
|
|
163
|
-
if (!
|
|
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,43 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface ToolDebugEntry {
|
|
5
|
+
readonly at: string;
|
|
6
|
+
readonly type: "tool_call" | "connect" | "inspect";
|
|
7
|
+
readonly requestId?: string;
|
|
8
|
+
readonly tenantId?: string;
|
|
9
|
+
readonly serverId?: string;
|
|
10
|
+
readonly toolName?: string;
|
|
11
|
+
readonly ok: boolean;
|
|
12
|
+
readonly durationMs?: number;
|
|
13
|
+
readonly error?: string;
|
|
14
|
+
readonly suggestion?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function toolDebugPath(): string {
|
|
18
|
+
return join(process.cwd(), ".nebutra", "debug", "tool-protocol.jsonl");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function appendToolDebug(
|
|
22
|
+
entry: Omit<ToolDebugEntry, "at"> & { at?: string },
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
const path = toolDebugPath();
|
|
25
|
+
await mkdir(dirname(path), { recursive: true });
|
|
26
|
+
await writeFile(path, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, {
|
|
27
|
+
flag: "a",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readToolDebug(limit = 10): Promise<ToolDebugEntry[]> {
|
|
32
|
+
try {
|
|
33
|
+
const raw = await readFile(toolDebugPath(), "utf8");
|
|
34
|
+
return raw
|
|
35
|
+
.trim()
|
|
36
|
+
.split("\n")
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.slice(-limit)
|
|
39
|
+
.map((line) => JSON.parse(line) as ToolDebugEntry);
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|