@khirby/plugin-pokelo 1.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@khirby/plugin-pokelo",
3
- "version": "1.0.1",
4
- "description": "Khirby — Pokelo RAG knowledge base context for AI Compose",
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 { PokeloContextServiceLike, PokeloFetchOpts } from '../../../packages/plugin-host/src';
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
- type McpToolResult = {
10
- result?: {
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 PokeloContextServiceLike {
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?: PokeloFetchOpts): Promise<string> {
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 PokeloContextServiceLike {
54
64
  const settled = await Promise.all(
55
65
  targetIds.map(async (projectId) => {
56
66
  try {
57
- const text = await this.callMcpTool(creds.baseUrl, creds.token, 'search_documents', {
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 PokeloContextServiceLike {
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 this.callMcpTool(creds.baseUrl, creds.token, 'list_projects', {
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 PokeloContextServiceLike {
129
214
  ): Promise<Map<string, string>> {
130
215
  const map = new Map<string, string>();
131
216
  try {
132
- const text = await this.callMcpTool(baseUrl, token, 'list_projects', { limit: 100 });
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 PokeloContextServiceLike {
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
- /** Parse last JSON-RPC payload from an SSE body (`data: {...}` lines). */
192
- export function parseSseJsonRpc(raw: string): McpToolResult {
193
- const dataLines: string[] = [];
194
- for (const line of raw.split(/\r?\n/)) {
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 function parseSearchMatches(text: string): string[] {
211
- if (!text.trim()) return [];
212
- try {
213
- const parsed = JSON.parse(text) as {
214
- matches?: Array<{ content?: string }>;
215
- matchCount?: number;
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';
@@ -1,60 +1,5 @@
1
- import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
2
-
3
- const ALGORITHM = 'aes-256-gcm';
4
- const IV_BYTES = 12;
5
- const TAG_BYTES = 16;
6
-
7
- function getKey(): Buffer {
8
- const raw = process.env.POKELO_SECRETS_KEY?.trim();
9
- if (!raw) {
10
- throw new Error('POKELO_SECRETS_KEY is not set');
11
- }
12
- let buf: Buffer;
13
- if (/^[0-9a-fA-F]{64}$/.test(raw)) {
14
- buf = Buffer.from(raw, 'hex');
15
- } else {
16
- buf = Buffer.from(raw, 'base64');
17
- }
18
- if (buf.length !== 32) {
19
- throw new Error(
20
- "POKELO_SECRETS_KEY must be 32 bytes as hex (64 chars) or base64 — generate with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
21
- );
22
- }
23
- return buf;
24
- }
25
-
26
- export function isPokeloSecretsKeyConfigured(): boolean {
27
- try {
28
- getKey();
29
- return true;
30
- } catch {
31
- return false;
32
- }
33
- }
34
-
35
- /**
36
- * Encrypts plaintext using AES-256-GCM.
37
- * Returns a base64 string: iv(12) + ciphertext + tag(16)
38
- */
39
- export function encrypt(plaintext: string): string {
40
- const key = getKey();
41
- const iv = randomBytes(IV_BYTES);
42
- const cipher = createCipheriv(ALGORITHM, key, iv);
43
- const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
44
- const tag = cipher.getAuthTag();
45
- return Buffer.concat([iv, encrypted, tag]).toString('base64');
46
- }
47
-
48
- /**
49
- * Decrypts a base64 blob produced by `encrypt`.
50
- */
51
- export function decrypt(ciphertext: string): string {
52
- const key = getKey();
53
- const buf = Buffer.from(ciphertext, 'base64');
54
- const iv = buf.subarray(0, IV_BYTES);
55
- const tag = buf.subarray(buf.length - TAG_BYTES);
56
- const encrypted = buf.subarray(IV_BYTES, buf.length - TAG_BYTES);
57
- const decipher = createDecipheriv(ALGORITHM, key, iv);
58
- decipher.setAuthTag(tag);
59
- return decipher.update(encrypted) + decipher.final('utf8');
60
- }
1
+ export {
2
+ decrypt,
3
+ encrypt,
4
+ isInstanceSecretsKeyConfigured as isPokeloSecretsKeyConfigured,
5
+ } from '../../../packages/plugin-host/src/instance-secrets';
@@ -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
+ }
@@ -99,7 +99,7 @@ export class PokeloSettingsService {
99
99
  let encryptedToken: string | undefined = undefined;
100
100
  if (dto.token !== undefined && dto.token.trim()) {
101
101
  if (!isPokeloSecretsKeyConfigured()) {
102
- throw AppException.badRequest('POKELO_SECRETS_KEY is not configured');
102
+ throw AppException.badRequest('KHIRBY_SECRETS_KEY is not configured');
103
103
  }
104
104
  encryptedToken = encrypt(dto.token.trim());
105
105
  }
@@ -1,12 +1,16 @@
1
1
  import { Global, Module } from '@nestjs/common';
2
- import { POKELO_CONTEXT_SERVICE } from '../../../packages/plugin-host/src';
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 AI Compose (sibling plugin module) can @Optional()-inject
9
- * POKELO_CONTEXT_SERVICE (ADR-0022).
12
+ * @Global so sibling plugins can resolve KNOWLEDGE_CONTEXT / KNOWLEDGE_TOOLS (ADR-0047, ADR-0050).
13
+ * POKELO_CONTEXT_SERVICE stays as a second provide until published consumers bump.
10
14
  */
11
15
  @Global()
12
16
  @Module({
@@ -14,8 +18,10 @@ import { PokeloSettingsController } from './pokelo-settings.controller';
14
18
  providers: [
15
19
  PokeloSettingsService,
16
20
  PokeloContextService,
21
+ { provide: KNOWLEDGE_CONTEXT, useExisting: PokeloContextService },
22
+ { provide: KNOWLEDGE_TOOLS, useExisting: PokeloContextService },
17
23
  { provide: POKELO_CONTEXT_SERVICE, useExisting: PokeloContextService },
18
24
  ],
19
- exports: [POKELO_CONTEXT_SERVICE],
25
+ exports: [KNOWLEDGE_CONTEXT, KNOWLEDGE_TOOLS, POKELO_CONTEXT_SERVICE],
20
26
  })
21
27
  export class PokeloModule {}
@@ -7,10 +7,16 @@ describe('pokelo-crypto', () => {
7
7
  const HEX_KEY = 'c'.repeat(64);
8
8
 
9
9
  beforeEach(() => {
10
+ delete process.env.KHIRBY_SECRETS_KEY;
11
+ delete process.env.MAIL_SECRETS_KEY;
12
+ delete process.env.AI_COMPOSE_SECRETS_KEY;
10
13
  process.env.POKELO_SECRETS_KEY = HEX_KEY;
11
14
  });
12
15
 
13
16
  afterEach(() => {
17
+ delete process.env.KHIRBY_SECRETS_KEY;
18
+ delete process.env.MAIL_SECRETS_KEY;
19
+ delete process.env.AI_COMPOSE_SECRETS_KEY;
14
20
  delete process.env.POKELO_SECRETS_KEY;
15
21
  });
16
22
 
@@ -26,9 +32,15 @@ describe('pokelo-crypto', () => {
26
32
  expect(isPokeloSecretsKeyConfigured()).toBe(false);
27
33
  });
28
34
 
35
+ it('isPokeloSecretsKeyConfigured returns true when KHIRBY_SECRETS_KEY is set', () => {
36
+ delete process.env.POKELO_SECRETS_KEY;
37
+ process.env.KHIRBY_SECRETS_KEY = HEX_KEY;
38
+ expect(isPokeloSecretsKeyConfigured()).toBe(true);
39
+ });
40
+
29
41
  it('throws on missing key at encrypt time', () => {
30
42
  delete process.env.POKELO_SECRETS_KEY;
31
- expect(() => encrypt('anything')).toThrow('POKELO_SECRETS_KEY is not set');
43
+ expect(() => encrypt('anything')).toThrow('KHIRBY_SECRETS_KEY is not set');
32
44
  });
33
45
  });
34
46
 
@@ -365,3 +377,99 @@ describe('PokeloContextService.fetchContext', () => {
365
377
  expect(await ctx.fetchContext('pricing')).toBe('');
366
378
  });
367
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
+ });