@khirby/plugin-pokelo 1.1.0 → 1.2.0
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/package.json +2 -2
- package/src/pokelo-context.service.ts +108 -116
- package/src/pokelo-mcp.client.ts +176 -0
- package/src/pokelo.module.ts +8 -3
- package/src/pokelo.spec.ts +96 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khirby/plugin-pokelo",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Khirby — Pokelo
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Khirby — Pokelo knowledge MCP proxy for Ask Khirby + RAG context for AI Compose",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"khirby-plugin"
|
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
KnowledgeContextLike,
|
|
4
|
+
KnowledgeFetchOpts,
|
|
5
|
+
KnowledgeMcpToolDef,
|
|
6
|
+
KnowledgeToolsLike,
|
|
7
|
+
} from '../../../packages/plugin-host/src';
|
|
3
8
|
import { PokeloSettingsService } from './pokelo-settings.service';
|
|
9
|
+
import {
|
|
10
|
+
callMcpTool,
|
|
11
|
+
extractProjectIdArg,
|
|
12
|
+
listMcpTools,
|
|
13
|
+
parseProjectList,
|
|
14
|
+
parseSearchMatches,
|
|
15
|
+
} from './pokelo-mcp.client';
|
|
4
16
|
|
|
5
17
|
const SNIPPET_LIMIT_TOTAL = 8;
|
|
6
18
|
const SNIPPET_LIMIT_PER_PROJECT = 3;
|
|
7
19
|
const SNIPPET_MAX_CHARS = 800;
|
|
20
|
+
const TOOL_RESULT_MAX = 12_000;
|
|
21
|
+
const TOOLS_CACHE_TTL_MS = 5 * 60_000;
|
|
8
22
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
content?: Array<{ type?: string; text?: string }>;
|
|
12
|
-
isError?: boolean;
|
|
13
|
-
};
|
|
14
|
-
error?: { message?: string };
|
|
15
|
-
};
|
|
23
|
+
/** Account-level create would bypass Settings binding (ADR-0050). */
|
|
24
|
+
const BLOCKED_MCP_TOOLS = new Set(['create_project']);
|
|
16
25
|
|
|
17
26
|
@Injectable()
|
|
18
|
-
export class PokeloContextService implements KnowledgeContextLike {
|
|
27
|
+
export class PokeloContextService implements KnowledgeContextLike, KnowledgeToolsLike {
|
|
19
28
|
private readonly logger = new Logger(PokeloContextService.name);
|
|
29
|
+
private toolsCache: { at: number; tools: KnowledgeMcpToolDef[] } | null = null;
|
|
20
30
|
|
|
21
31
|
constructor(private readonly settings: PokeloSettingsService) {}
|
|
22
32
|
|
|
23
|
-
async fetchContext(query: string, opts?:
|
|
33
|
+
async fetchContext(query: string, opts?: KnowledgeFetchOpts): Promise<string> {
|
|
24
34
|
try {
|
|
25
35
|
if (!(await this.settings.isPluginEnabled())) {
|
|
26
36
|
return '';
|
|
@@ -54,7 +64,7 @@ export class PokeloContextService implements KnowledgeContextLike {
|
|
|
54
64
|
const settled = await Promise.all(
|
|
55
65
|
targetIds.map(async (projectId) => {
|
|
56
66
|
try {
|
|
57
|
-
const text = await
|
|
67
|
+
const text = await callMcpTool(creds.baseUrl, creds.token, 'search_documents', {
|
|
58
68
|
projectId,
|
|
59
69
|
query: trimmed.slice(0, 4000),
|
|
60
70
|
limit: perProjectLimit,
|
|
@@ -99,13 +109,88 @@ export class PokeloContextService implements KnowledgeContextLike {
|
|
|
99
109
|
}
|
|
100
110
|
}
|
|
101
111
|
|
|
112
|
+
async listTools(): Promise<KnowledgeMcpToolDef[]> {
|
|
113
|
+
if (!(await this.settings.isPluginEnabled())) {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const creds = await this.settings.getCredentials();
|
|
117
|
+
if (!creds?.token || creds.projectIds.length === 0) {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
if (this.toolsCache && now - this.toolsCache.at < TOOLS_CACHE_TTL_MS) {
|
|
123
|
+
return this.toolsCache.tools;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const tools = (await listMcpTools(creds.baseUrl, creds.token)).filter(
|
|
127
|
+
(t) => !BLOCKED_MCP_TOOLS.has(t.name),
|
|
128
|
+
);
|
|
129
|
+
this.toolsCache = { at: now, tools };
|
|
130
|
+
return tools;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async callTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
134
|
+
if (!(await this.settings.isPluginEnabled())) {
|
|
135
|
+
throw new Error('Pokelo plugin is disabled');
|
|
136
|
+
}
|
|
137
|
+
if (BLOCKED_MCP_TOOLS.has(name)) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`${name} is not available from Ask Khirby — bind projects in Settings → Integrations → Pokelo`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const creds = await this.settings.getCredentials();
|
|
144
|
+
if (!creds?.token) {
|
|
145
|
+
throw new Error('Pokelo token is not configured');
|
|
146
|
+
}
|
|
147
|
+
if (creds.projectIds.length === 0) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'No Pokelo projects bound — select projects in Settings → Integrations → Pokelo',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const projectId = extractProjectIdArg(args);
|
|
154
|
+
if (projectId && !creds.projectIds.includes(projectId)) {
|
|
155
|
+
throw new Error(`Project ${projectId} is not in the operator-bound Pokelo set`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (name === 'list_projects') {
|
|
159
|
+
const text = await callMcpTool(creds.baseUrl, creds.token, 'list_projects', {
|
|
160
|
+
...args,
|
|
161
|
+
limit: typeof args.limit === 'number' ? args.limit : 100,
|
|
162
|
+
});
|
|
163
|
+
const all = parseProjectList(text);
|
|
164
|
+
const bound = new Set(creds.projectIds);
|
|
165
|
+
const filtered = all.filter((p) => bound.has(p.id));
|
|
166
|
+
const seen = new Set(filtered.map((p) => p.id));
|
|
167
|
+
for (const id of creds.projectIds) {
|
|
168
|
+
if (!seen.has(id)) filtered.push({ id, name: id });
|
|
169
|
+
}
|
|
170
|
+
const payload = JSON.stringify({
|
|
171
|
+
items: filtered.map((p) => ({ projectId: p.id, name: p.name })),
|
|
172
|
+
total: filtered.length,
|
|
173
|
+
});
|
|
174
|
+
return truncateToolResult(payload);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!projectId) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`projectId is required and must be one of the bound Pokelo projects (${creds.projectIds.join(', ')})`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const text = await callMcpTool(creds.baseUrl, creds.token, name, args);
|
|
184
|
+
return truncateToolResult(text);
|
|
185
|
+
}
|
|
186
|
+
|
|
102
187
|
async listProjects(): Promise<Array<{ id: string; name: string }>> {
|
|
103
188
|
const creds = await this.settings.getCredentials();
|
|
104
189
|
if (!creds?.token) {
|
|
105
190
|
return [];
|
|
106
191
|
}
|
|
107
192
|
|
|
108
|
-
const text = await
|
|
193
|
+
const text = await callMcpTool(creds.baseUrl, creds.token, 'list_projects', {
|
|
109
194
|
limit: 100,
|
|
110
195
|
});
|
|
111
196
|
|
|
@@ -129,7 +214,7 @@ export class PokeloContextService implements KnowledgeContextLike {
|
|
|
129
214
|
): Promise<Map<string, string>> {
|
|
130
215
|
const map = new Map<string, string>();
|
|
131
216
|
try {
|
|
132
|
-
const text = await
|
|
217
|
+
const text = await callMcpTool(baseUrl, token, 'list_projects', { limit: 100 });
|
|
133
218
|
for (const p of parseProjectList(text)) {
|
|
134
219
|
map.set(p.id, p.name);
|
|
135
220
|
}
|
|
@@ -141,110 +226,17 @@ export class PokeloContextService implements KnowledgeContextLike {
|
|
|
141
226
|
}
|
|
142
227
|
return map;
|
|
143
228
|
}
|
|
144
|
-
|
|
145
|
-
private async callMcpTool(
|
|
146
|
-
baseUrl: string,
|
|
147
|
-
token: string,
|
|
148
|
-
name: string,
|
|
149
|
-
args: Record<string, unknown>,
|
|
150
|
-
): Promise<string> {
|
|
151
|
-
const url = `${baseUrl.replace(/\/$/, '')}/mcp`;
|
|
152
|
-
const response = await fetch(url, {
|
|
153
|
-
method: 'POST',
|
|
154
|
-
headers: {
|
|
155
|
-
'Content-Type': 'application/json',
|
|
156
|
-
Accept: 'application/json, text/event-stream',
|
|
157
|
-
Authorization: `Bearer ${token}`,
|
|
158
|
-
},
|
|
159
|
-
body: JSON.stringify({
|
|
160
|
-
jsonrpc: '2.0',
|
|
161
|
-
id: 1,
|
|
162
|
-
method: 'tools/call',
|
|
163
|
-
params: { name, arguments: args },
|
|
164
|
-
}),
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
if (!response.ok) {
|
|
168
|
-
const errText = await response.text().catch(() => 'unknown error');
|
|
169
|
-
throw new Error(`Pokelo MCP ${response.status}: ${errText.slice(0, 200)}`);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const contentType = response.headers.get('content-type') ?? '';
|
|
173
|
-
const raw = await response.text();
|
|
174
|
-
const envelope = contentType.includes('text/event-stream')
|
|
175
|
-
? parseSseJsonRpc(raw)
|
|
176
|
-
: (JSON.parse(raw) as McpToolResult);
|
|
177
|
-
|
|
178
|
-
if (envelope.error) {
|
|
179
|
-
throw new Error(envelope.error.message ?? 'Pokelo MCP tool error');
|
|
180
|
-
}
|
|
181
|
-
// Pokelo maps tool failures to CallToolResult.isError (HTTP 200), not JSON-RPC error.
|
|
182
|
-
if (envelope.result?.isError) {
|
|
183
|
-
const msg = envelope.result.content?.[0]?.text?.trim() || 'Pokelo MCP tool error';
|
|
184
|
-
throw new Error(msg);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return envelope.result?.content?.[0]?.text ?? '';
|
|
188
|
-
}
|
|
189
229
|
}
|
|
190
230
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (line.startsWith('data:')) {
|
|
196
|
-
dataLines.push(line.slice(5).trim());
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
if (dataLines.length === 0) {
|
|
200
|
-
return JSON.parse(raw) as McpToolResult;
|
|
201
|
-
}
|
|
202
|
-
for (let i = dataLines.length - 1; i >= 0; i--) {
|
|
203
|
-
if (dataLines[i] && dataLines[i] !== '[DONE]') {
|
|
204
|
-
return JSON.parse(dataLines[i]) as McpToolResult;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
throw new Error('Empty SSE response from Pokelo MCP');
|
|
231
|
+
function truncateToolResult(text: string): string {
|
|
232
|
+
const trimmed = text.trim();
|
|
233
|
+
if (trimmed.length <= TOOL_RESULT_MAX) return trimmed;
|
|
234
|
+
return `${trimmed.slice(0, TOOL_RESULT_MAX)}…`;
|
|
208
235
|
}
|
|
209
236
|
|
|
210
|
-
export
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
};
|
|
217
|
-
if (Array.isArray(parsed.matches)) {
|
|
218
|
-
return parsed.matches
|
|
219
|
-
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
|
220
|
-
.filter(Boolean);
|
|
221
|
-
}
|
|
222
|
-
} catch {
|
|
223
|
-
// fall through
|
|
224
|
-
}
|
|
225
|
-
return [text];
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export function parseProjectList(text: string): Array<{ id: string; name: string }> {
|
|
229
|
-
if (!text.trim()) return [];
|
|
230
|
-
try {
|
|
231
|
-
const parsed = JSON.parse(text) as {
|
|
232
|
-
items?: Array<{ id?: string; projectId?: string; name?: string }>;
|
|
233
|
-
};
|
|
234
|
-
if (Array.isArray(parsed.items)) {
|
|
235
|
-
return parsed.items
|
|
236
|
-
.map((p) => {
|
|
237
|
-
const id =
|
|
238
|
-
(typeof p.projectId === 'string' && p.projectId.trim()) ||
|
|
239
|
-
(typeof p.id === 'string' && p.id.trim()) ||
|
|
240
|
-
'';
|
|
241
|
-
const name = typeof p.name === 'string' ? p.name.trim() : '';
|
|
242
|
-
return { id, name };
|
|
243
|
-
})
|
|
244
|
-
.filter((p) => p.id && p.name);
|
|
245
|
-
}
|
|
246
|
-
} catch {
|
|
247
|
-
// ignore
|
|
248
|
-
}
|
|
249
|
-
return [];
|
|
250
|
-
}
|
|
237
|
+
// Re-export parsers for existing specs
|
|
238
|
+
export {
|
|
239
|
+
parseSseJsonRpc,
|
|
240
|
+
parseSearchMatches,
|
|
241
|
+
parseProjectList,
|
|
242
|
+
} from './pokelo-mcp.client';
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { KnowledgeMcpToolDef } from '../../../packages/plugin-host/src';
|
|
2
|
+
|
|
3
|
+
export type McpToolResult = {
|
|
4
|
+
result?: {
|
|
5
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
6
|
+
isError?: boolean;
|
|
7
|
+
tools?: Array<{
|
|
8
|
+
name?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
inputSchema?: Record<string, unknown>;
|
|
11
|
+
}>;
|
|
12
|
+
};
|
|
13
|
+
error?: { message?: string };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Parse last JSON-RPC payload from an SSE body (`data: {...}` lines). */
|
|
17
|
+
export function parseSseJsonRpc(raw: string): McpToolResult {
|
|
18
|
+
const dataLines: string[] = [];
|
|
19
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
20
|
+
if (line.startsWith('data:')) {
|
|
21
|
+
dataLines.push(line.slice(5).trim());
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (dataLines.length === 0) {
|
|
25
|
+
return JSON.parse(raw) as McpToolResult;
|
|
26
|
+
}
|
|
27
|
+
for (let i = dataLines.length - 1; i >= 0; i--) {
|
|
28
|
+
if (dataLines[i] && dataLines[i] !== '[DONE]') {
|
|
29
|
+
return JSON.parse(dataLines[i]) as McpToolResult;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw new Error('Empty SSE response from Pokelo MCP');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseSearchMatches(text: string): string[] {
|
|
36
|
+
if (!text.trim()) return [];
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(text) as {
|
|
39
|
+
matches?: Array<{ content?: string }>;
|
|
40
|
+
matchCount?: number;
|
|
41
|
+
};
|
|
42
|
+
if (Array.isArray(parsed.matches)) {
|
|
43
|
+
return parsed.matches
|
|
44
|
+
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// fall through
|
|
49
|
+
}
|
|
50
|
+
return [text];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parseProjectList(text: string): Array<{ id: string; name: string }> {
|
|
54
|
+
if (!text.trim()) return [];
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(text) as {
|
|
57
|
+
items?: Array<{ id?: string; projectId?: string; name?: string }>;
|
|
58
|
+
};
|
|
59
|
+
if (Array.isArray(parsed.items)) {
|
|
60
|
+
return parsed.items
|
|
61
|
+
.map((p) => {
|
|
62
|
+
const id =
|
|
63
|
+
(typeof p.projectId === 'string' && p.projectId.trim()) ||
|
|
64
|
+
(typeof p.id === 'string' && p.id.trim()) ||
|
|
65
|
+
'';
|
|
66
|
+
const name = typeof p.name === 'string' ? p.name.trim() : '';
|
|
67
|
+
return { id, name };
|
|
68
|
+
})
|
|
69
|
+
.filter((p) => p.id && p.name);
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseMcpToolList(envelope: McpToolResult): KnowledgeMcpToolDef[] {
|
|
78
|
+
const tools = envelope.result?.tools;
|
|
79
|
+
if (!Array.isArray(tools)) return [];
|
|
80
|
+
return tools
|
|
81
|
+
.map((t) => {
|
|
82
|
+
const name = typeof t.name === 'string' ? t.name.trim() : '';
|
|
83
|
+
if (!name) return null;
|
|
84
|
+
const description = typeof t.description === 'string' ? t.description : name;
|
|
85
|
+
const inputSchema =
|
|
86
|
+
t.inputSchema && typeof t.inputSchema === 'object' && !Array.isArray(t.inputSchema)
|
|
87
|
+
? t.inputSchema
|
|
88
|
+
: { type: 'object', properties: {} };
|
|
89
|
+
return { name, description, inputSchema };
|
|
90
|
+
})
|
|
91
|
+
.filter((t): t is KnowledgeMcpToolDef => t !== null);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Extract projectId from MCP tool args (common field names). */
|
|
95
|
+
export function extractProjectIdArg(args: Record<string, unknown>): string | null {
|
|
96
|
+
for (const key of ['projectId', 'project_id', 'project']) {
|
|
97
|
+
const raw = args[key];
|
|
98
|
+
if (typeof raw === 'string' && raw.trim()) return raw.trim();
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Shared Pokelo MCP HTTP transport (tools/list + tools/call).
|
|
105
|
+
* Used by fetchContext enrichment and Ask Khirby tool proxy (ADR-0050).
|
|
106
|
+
*/
|
|
107
|
+
export async function mcpJsonRpc(
|
|
108
|
+
baseUrl: string,
|
|
109
|
+
token: string,
|
|
110
|
+
method: 'tools/list' | 'tools/call',
|
|
111
|
+
params?: Record<string, unknown>,
|
|
112
|
+
): Promise<McpToolResult> {
|
|
113
|
+
const url = `${baseUrl.replace(/\/$/, '')}/mcp`;
|
|
114
|
+
const body: Record<string, unknown> = {
|
|
115
|
+
jsonrpc: '2.0',
|
|
116
|
+
id: 1,
|
|
117
|
+
method,
|
|
118
|
+
};
|
|
119
|
+
if (params !== undefined) {
|
|
120
|
+
body.params = params;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const response = await fetch(url, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: {
|
|
126
|
+
'Content-Type': 'application/json',
|
|
127
|
+
Accept: 'application/json, text/event-stream',
|
|
128
|
+
Authorization: `Bearer ${token}`,
|
|
129
|
+
},
|
|
130
|
+
body: JSON.stringify(body),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
const errText = await response.text().catch(() => 'unknown error');
|
|
135
|
+
throw new Error(`Pokelo MCP ${response.status}: ${errText.slice(0, 200)}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
139
|
+
const raw = await response.text();
|
|
140
|
+
return contentType.includes('text/event-stream')
|
|
141
|
+
? parseSseJsonRpc(raw)
|
|
142
|
+
: (JSON.parse(raw) as McpToolResult);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function callMcpTool(
|
|
146
|
+
baseUrl: string,
|
|
147
|
+
token: string,
|
|
148
|
+
name: string,
|
|
149
|
+
args: Record<string, unknown>,
|
|
150
|
+
): Promise<string> {
|
|
151
|
+
const envelope = await mcpJsonRpc(baseUrl, token, 'tools/call', {
|
|
152
|
+
name,
|
|
153
|
+
arguments: args,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (envelope.error) {
|
|
157
|
+
throw new Error(envelope.error.message ?? 'Pokelo MCP tool error');
|
|
158
|
+
}
|
|
159
|
+
if (envelope.result?.isError) {
|
|
160
|
+
const msg = envelope.result.content?.[0]?.text?.trim() || 'Pokelo MCP tool error';
|
|
161
|
+
throw new Error(msg);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return envelope.result?.content?.[0]?.text ?? '';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function listMcpTools(
|
|
168
|
+
baseUrl: string,
|
|
169
|
+
token: string,
|
|
170
|
+
): Promise<KnowledgeMcpToolDef[]> {
|
|
171
|
+
const envelope = await mcpJsonRpc(baseUrl, token, 'tools/list', {});
|
|
172
|
+
if (envelope.error) {
|
|
173
|
+
throw new Error(envelope.error.message ?? 'Pokelo MCP tools/list error');
|
|
174
|
+
}
|
|
175
|
+
return parseMcpToolList(envelope);
|
|
176
|
+
}
|
package/src/pokelo.module.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { Global, Module } from '@nestjs/common';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
KNOWLEDGE_CONTEXT,
|
|
4
|
+
KNOWLEDGE_TOOLS,
|
|
5
|
+
POKELO_CONTEXT_SERVICE,
|
|
6
|
+
} from '../../../packages/plugin-host/src';
|
|
3
7
|
import { PokeloSettingsService } from './pokelo-settings.service';
|
|
4
8
|
import { PokeloContextService } from './pokelo-context.service';
|
|
5
9
|
import { PokeloSettingsController } from './pokelo-settings.controller';
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
|
-
* @Global so sibling plugins can
|
|
12
|
+
* @Global so sibling plugins can resolve KNOWLEDGE_CONTEXT / KNOWLEDGE_TOOLS (ADR-0047, ADR-0050).
|
|
9
13
|
* POKELO_CONTEXT_SERVICE stays as a second provide until published consumers bump.
|
|
10
14
|
*/
|
|
11
15
|
@Global()
|
|
@@ -15,8 +19,9 @@ import { PokeloSettingsController } from './pokelo-settings.controller';
|
|
|
15
19
|
PokeloSettingsService,
|
|
16
20
|
PokeloContextService,
|
|
17
21
|
{ provide: KNOWLEDGE_CONTEXT, useExisting: PokeloContextService },
|
|
22
|
+
{ provide: KNOWLEDGE_TOOLS, useExisting: PokeloContextService },
|
|
18
23
|
{ provide: POKELO_CONTEXT_SERVICE, useExisting: PokeloContextService },
|
|
19
24
|
],
|
|
20
|
-
exports: [KNOWLEDGE_CONTEXT, POKELO_CONTEXT_SERVICE],
|
|
25
|
+
exports: [KNOWLEDGE_CONTEXT, KNOWLEDGE_TOOLS, POKELO_CONTEXT_SERVICE],
|
|
21
26
|
})
|
|
22
27
|
export class PokeloModule {}
|
package/src/pokelo.spec.ts
CHANGED
|
@@ -377,3 +377,99 @@ describe('PokeloContextService.fetchContext', () => {
|
|
|
377
377
|
expect(await ctx.fetchContext('pricing')).toBe('');
|
|
378
378
|
});
|
|
379
379
|
});
|
|
380
|
+
|
|
381
|
+
describe('PokeloContextService listTools / callTool', () => {
|
|
382
|
+
const HEX_KEY = 'f'.repeat(64);
|
|
383
|
+
|
|
384
|
+
beforeEach(() => {
|
|
385
|
+
process.env.POKELO_SECRETS_KEY = HEX_KEY;
|
|
386
|
+
global.fetch = jest.fn();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
afterEach(() => {
|
|
390
|
+
delete process.env.POKELO_SECRETS_KEY;
|
|
391
|
+
jest.restoreAllMocks();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
function settingsWithProjects(ids: string[]) {
|
|
395
|
+
return new PokeloSettingsService(
|
|
396
|
+
makeMockDb({
|
|
397
|
+
encryptedToken: encrypt('mcp_tok'),
|
|
398
|
+
projectIds: ids,
|
|
399
|
+
projectId: ids[0],
|
|
400
|
+
baseUrl: 'https://rag.bearly.pro/v1',
|
|
401
|
+
}) as any,
|
|
402
|
+
makeMockRegistry(true) as any,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
it('listTools drops create_project and caches the catalog', async () => {
|
|
407
|
+
const listPayload = {
|
|
408
|
+
jsonrpc: '2.0',
|
|
409
|
+
result: {
|
|
410
|
+
tools: [
|
|
411
|
+
{ name: 'list_projects', description: 'List', inputSchema: { type: 'object' } },
|
|
412
|
+
{ name: 'create_project', description: 'Create', inputSchema: { type: 'object' } },
|
|
413
|
+
{
|
|
414
|
+
name: 'search_documents',
|
|
415
|
+
description: 'Search',
|
|
416
|
+
inputSchema: { type: 'object', properties: { projectId: { type: 'string' } } },
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
422
|
+
ok: true,
|
|
423
|
+
headers: { get: () => 'application/json' },
|
|
424
|
+
text: async () => JSON.stringify(listPayload),
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const ctx = new PokeloContextService(settingsWithProjects(['a', 'b']));
|
|
428
|
+
const tools = await ctx.listTools();
|
|
429
|
+
expect(tools.map((t) => t.name)).toEqual(['list_projects', 'search_documents']);
|
|
430
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
431
|
+
|
|
432
|
+
await ctx.listTools();
|
|
433
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('callTool rejects unbound projectId and filters list_projects to bound set', async () => {
|
|
437
|
+
const listPayload = {
|
|
438
|
+
jsonrpc: '2.0',
|
|
439
|
+
result: {
|
|
440
|
+
content: [
|
|
441
|
+
{
|
|
442
|
+
type: 'text',
|
|
443
|
+
text: JSON.stringify({
|
|
444
|
+
items: [
|
|
445
|
+
{ projectId: 'a', name: 'CRM' },
|
|
446
|
+
{ projectId: 'b', name: 'Other' },
|
|
447
|
+
{ projectId: 'c', name: 'Unbound' },
|
|
448
|
+
],
|
|
449
|
+
}),
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
(global.fetch as jest.Mock).mockResolvedValue({
|
|
455
|
+
ok: true,
|
|
456
|
+
headers: { get: () => 'application/json' },
|
|
457
|
+
text: async () => JSON.stringify(listPayload),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const ctx = new PokeloContextService(settingsWithProjects(['a', 'b']));
|
|
461
|
+
await expect(ctx.callTool('search_documents', { projectId: 'c', query: 'x' })).rejects.toThrow(
|
|
462
|
+
/not in the operator-bound/,
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const listed = await ctx.callTool('list_projects', {});
|
|
466
|
+
const parsed = JSON.parse(listed) as { items: Array<{ projectId: string }> };
|
|
467
|
+
expect(parsed.items.map((i) => i.projectId).sort()).toEqual(['a', 'b']);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('callTool blocks create_project', async () => {
|
|
471
|
+
const ctx = new PokeloContextService(settingsWithProjects(['a']));
|
|
472
|
+
await expect(ctx.callTool('create_project', { name: 'x' })).rejects.toThrow(/not available/);
|
|
473
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
474
|
+
});
|
|
475
|
+
});
|