@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@khirby/plugin-pokelo",
3
- "version": "1.1.0",
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 { KnowledgeContextLike, 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 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?: 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 KnowledgeContextLike {
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 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 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 KnowledgeContextLike {
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 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
- /** 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';
@@ -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
+ }
@@ -1,11 +1,15 @@
1
1
  import { Global, Module } from '@nestjs/common';
2
- import { KNOWLEDGE_CONTEXT, 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 sibling plugins can @Optional()-inject KNOWLEDGE_CONTEXT (ADR-0047).
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 {}
@@ -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
+ });