pi-mcp-adapter 2.8.0 → 2.10.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/mcp-auth-flow.ts CHANGED
@@ -38,10 +38,15 @@ export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
38
38
 
39
39
  // Track pending transports for auth completion
40
40
  const pendingTransports = new Map<string, StreamableHTTPClientTransport>()
41
+ const pendingAuthStates = new Map<string, string>()
42
+ const pendingAuthCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>()
41
43
 
42
44
  // Deduplicate concurrent authenticate() calls per server.
43
45
  const pendingAuthentications = new Map<string, Promise<AuthStatus>>()
44
46
 
47
+ /** Timeout for manual auth completion (5 minutes) */
48
+ const MANUAL_AUTH_TIMEOUT_MS = 5 * 60 * 1000
49
+
45
50
  /**
46
51
  * Generate a cryptographically secure random state parameter.
47
52
  */
@@ -214,18 +219,124 @@ export async function startAuth(
214
219
  if (!capturedUrl) {
215
220
  throw new UnauthorizedError("OAuth authorization URL was not provided")
216
221
  }
217
- pendingTransports.set(
218
- serverName,
219
- new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider }),
220
- )
222
+ const pendingTransport = new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider })
223
+ await setPendingTransport(serverName, pendingTransport, oauthState)
221
224
  return { authorizationUrl: capturedUrl.toString() }
222
225
  } catch (error) {
223
- releaseCallbackServer(oauthState)
224
- await clearOAuthState(serverName)
226
+ await clearPendingAuth(serverName, oauthState)
225
227
  throw error
226
228
  }
227
229
  }
228
230
 
231
+ async function setPendingTransport(
232
+ serverName: string,
233
+ transport: StreamableHTTPClientTransport,
234
+ oauthState: string,
235
+ ): Promise<void> {
236
+ await clearPendingAuth(serverName)
237
+ pendingTransports.set(serverName, transport)
238
+ pendingAuthStates.set(serverName, oauthState)
239
+ const cleanupTimer = setTimeout(() => {
240
+ void clearPendingAuth(serverName, oauthState)
241
+ }, MANUAL_AUTH_TIMEOUT_MS)
242
+ cleanupTimer.unref?.()
243
+ pendingAuthCleanupTimers.set(serverName, cleanupTimer)
244
+ }
245
+
246
+ async function clearPendingAuth(serverName: string, oauthState?: string): Promise<void> {
247
+ const pendingState = pendingAuthStates.get(serverName)
248
+ if (oauthState && pendingState && pendingState !== oauthState) return
249
+
250
+ const timer = pendingAuthCleanupTimers.get(serverName)
251
+ if (timer) {
252
+ clearTimeout(timer)
253
+ pendingAuthCleanupTimers.delete(serverName)
254
+ }
255
+
256
+ const transport = pendingTransports.get(serverName)
257
+ pendingTransports.delete(serverName)
258
+ pendingAuthStates.delete(serverName)
259
+ const stateToRelease = pendingState ?? oauthState
260
+ if (stateToRelease) {
261
+ releaseCallbackServer(stateToRelease)
262
+ const storedState = await getOAuthState(serverName)
263
+ if (storedState === stateToRelease) {
264
+ await clearOAuthState(serverName)
265
+ }
266
+ }
267
+ if (transport) {
268
+ await transport.close().catch(() => {})
269
+ }
270
+ }
271
+
272
+ function getSearchParamsFromInput(input: string): URLSearchParams | undefined {
273
+ try {
274
+ const url = new URL(input)
275
+ const params = new URLSearchParams(url.search)
276
+ if (url.hash) {
277
+ const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash
278
+ const hashParams = new URLSearchParams(hash)
279
+ for (const [key, value] of hashParams) {
280
+ if (!params.has(key)) params.set(key, value)
281
+ }
282
+ }
283
+ return params
284
+ } catch {
285
+ const query = input.includes("?") ? input.slice(input.indexOf("?") + 1) : input
286
+ const params = new URLSearchParams(query.startsWith("#") ? query.slice(1) : query)
287
+ return params.has("code") || params.has("state") || params.has("error") ? params : undefined
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Extract an OAuth authorization code from either a raw code, a query string,
293
+ * or the full localhost redirect URL copied from the browser address bar.
294
+ */
295
+ export function parseAuthorizationCodeInput(input: string, expectedState?: string): string {
296
+ const trimmed = input.trim()
297
+ if (!trimmed) {
298
+ throw new Error("Authorization code or redirect URL is required")
299
+ }
300
+
301
+ const params = getSearchParamsFromInput(trimmed)
302
+ if (params) {
303
+ const error = params.get("error")
304
+ if (error) {
305
+ const description = params.get("error_description")
306
+ throw new Error(description ? `${error}: ${description}` : error)
307
+ }
308
+
309
+ const state = params.get("state")
310
+ if (expectedState && !state) {
311
+ throw new Error("OAuth state missing from redirect URL")
312
+ }
313
+ if (expectedState && state !== expectedState) {
314
+ throw new Error("OAuth state mismatch - potential CSRF attack")
315
+ }
316
+
317
+ const code = params.get("code")
318
+ if (code) return code
319
+ }
320
+
321
+ if (/^[A-Za-z0-9._~+/=-]+$/.test(trimmed)) {
322
+ return trimmed
323
+ }
324
+
325
+ throw new Error("Could not find an OAuth authorization code in the provided input")
326
+ }
327
+
328
+ /**
329
+ * Complete OAuth authentication from manual user input.
330
+ */
331
+ export async function completeAuthFromInput(
332
+ serverName: string,
333
+ input: string,
334
+ ): Promise<AuthStatus> {
335
+ const oauthState = await getOAuthState(serverName)
336
+ const code = parseAuthorizationCodeInput(input, oauthState)
337
+ return completeAuth(serverName, code)
338
+ }
339
+
229
340
  /**
230
341
  * Complete OAuth authentication with the authorization code.
231
342
  */
@@ -245,12 +356,7 @@ export async function completeAuth(
245
356
  await transport.finishAuth(authorizationCode)
246
357
  return "authenticated"
247
358
  } finally {
248
- if (oauthState) {
249
- releaseCallbackServer(oauthState)
250
- }
251
- await clearOAuthState(serverName)
252
- pendingTransports.delete(serverName)
253
- await transport.close().catch(() => {})
359
+ await clearPendingAuth(serverName, oauthState)
254
360
  }
255
361
  }
256
362
 
@@ -291,16 +397,13 @@ export async function authenticate(
291
397
  const callbackPromise = waitForCallback(oauthState)
292
398
 
293
399
  try {
294
- // Open browser
295
- console.log(`MCP Auth: Opening browser for ${serverName}`)
400
+ // Open browser. Always print the URL first so remote/headless users can copy it
401
+ // even when the OS browser handoff is unavailable or invisible.
402
+ console.log(`MCP Auth: Open this URL to authenticate ${serverName}:\n${authorizationUrl}`)
296
403
  try {
297
404
  await open(authorizationUrl)
298
405
  } catch (error) {
299
- console.warn(`MCP Auth: Failed to open browser for ${serverName}`, { error })
300
- throw new Error(
301
- `Could not open browser. Please open this URL manually: ${authorizationUrl}`,
302
- { cause: error },
303
- )
406
+ console.warn(`MCP Auth: Failed to open browser for ${serverName}; waiting for manual callback`, { error })
304
407
  }
305
408
 
306
409
  // Wait for callback
@@ -318,12 +421,7 @@ export async function authenticate(
318
421
  return await completeAuth(serverName, code)
319
422
  } catch (error) {
320
423
  cancelPendingCallback(oauthState)
321
- await clearOAuthState(serverName)
322
- const pendingTransport = pendingTransports.get(serverName)
323
- if (pendingTransport) {
324
- pendingTransports.delete(serverName)
325
- await pendingTransport.close().catch(() => {})
326
- }
424
+ await clearPendingAuth(serverName, oauthState)
327
425
  throw error
328
426
  }
329
427
  })()
@@ -418,11 +516,7 @@ export async function removeAuth(serverName: string): Promise<void> {
418
516
  if (oauthState) {
419
517
  cancelPendingCallback(oauthState)
420
518
  }
421
- const pendingTransport = pendingTransports.get(serverName)
422
- if (pendingTransport) {
423
- pendingTransports.delete(serverName)
424
- await pendingTransport.close().catch(() => {})
425
- }
519
+ await clearPendingAuth(serverName, oauthState)
426
520
  clearAllCredentials(serverName)
427
521
  await clearOAuthState(serverName)
428
522
  console.log(`MCP Auth: Removed credentials for ${serverName}`)
@@ -458,5 +552,8 @@ export async function initializeOAuth(): Promise<void> {}
458
552
  * Stops the callback server and cancels pending auths.
459
553
  */
460
554
  export async function shutdownOAuth(): Promise<void> {
555
+ for (const serverName of Array.from(pendingTransports.keys())) {
556
+ await clearPendingAuth(serverName)
557
+ }
461
558
  await stopCallbackServer()
462
559
  }
@@ -5,7 +5,7 @@
5
5
  * Handles OAuth client registration, token storage, and authorization redirection.
6
6
  */
7
7
 
8
- import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
8
+ import type { AddClientAuthentication, OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
9
9
  import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
10
10
  import type {
11
11
  OAuthClientMetadata,
@@ -135,6 +135,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
135
135
  grant_types: ["authorization_code", "refresh_token"],
136
136
  response_types: ["code"],
137
137
  token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
138
+ ...(this.config.scope !== undefined ? { scope: this.config.scope } : {}),
138
139
  }
139
140
  }
140
141
 
@@ -305,6 +306,52 @@ export class McpOAuthProvider implements OAuthClientProvider {
305
306
  }
306
307
  }
307
308
 
309
+ /**
310
+ * Adds configured authorization-code scope without replacing the SDK's
311
+ * default token endpoint authentication behavior.
312
+ */
313
+ addClientAuthentication: AddClientAuthentication = async (headers, params, _url, metadata) => {
314
+ if (params.get("grant_type") === "authorization_code" && !params.has("scope") && this.config.scope) {
315
+ params.set("scope", this.config.scope)
316
+ }
317
+
318
+ const clientInfo = await this.clientInformation()
319
+ if (!clientInfo) {
320
+ return
321
+ }
322
+
323
+ const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []
324
+ const hasClientSecret = clientInfo.client_secret !== undefined
325
+ let authMethod: "client_secret_basic" | "client_secret_post" | "none"
326
+
327
+ if (supportedMethods.length === 0) {
328
+ authMethod = hasClientSecret ? "client_secret_post" : "none"
329
+ } else if (hasClientSecret && supportedMethods.includes("client_secret_basic")) {
330
+ authMethod = "client_secret_basic"
331
+ } else if (hasClientSecret && supportedMethods.includes("client_secret_post")) {
332
+ authMethod = "client_secret_post"
333
+ } else if (supportedMethods.includes("none")) {
334
+ authMethod = "none"
335
+ } else {
336
+ authMethod = hasClientSecret ? "client_secret_post" : "none"
337
+ }
338
+
339
+ if (authMethod === "client_secret_basic") {
340
+ if (!clientInfo.client_secret) {
341
+ throw new Error("client_secret_basic authentication requires a client_secret")
342
+ }
343
+ headers.set("Authorization", `Basic ${Buffer.from(`${clientInfo.client_id}:${clientInfo.client_secret}`).toString("base64")}`)
344
+ return
345
+ }
346
+
347
+ if (!params.has("client_id")) {
348
+ params.set("client_id", clientInfo.client_id)
349
+ }
350
+ if (authMethod === "client_secret_post" && clientInfo.client_secret && !params.has("client_secret")) {
351
+ params.set("client_secret", clientInfo.client_secret)
352
+ }
353
+ }
354
+
308
355
  prepareTokenRequest(scope?: string): URLSearchParams | undefined {
309
356
  if (!this.usesClientCredentials) {
310
357
  return undefined
package/mcp-panel.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
2
3
  import { isToolExcluded } from "./types.ts";
3
4
  import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance } from "./types.ts";
4
5
  import { resourceNameToToolName } from "./resource-tools.ts";
@@ -126,6 +127,7 @@ class McpPanel {
126
127
  private tui: { requestRender(): void };
127
128
  private t = DEFAULT_THEME;
128
129
  private authOnly: boolean;
130
+ private keys: PanelKeys;
129
131
 
130
132
  private static readonly MAX_VISIBLE = 12;
131
133
  private static readonly INACTIVITY_MS = 60_000;
@@ -137,11 +139,12 @@ class McpPanel {
137
139
  private callbacks: McpPanelCallbacks,
138
140
  tui: { requestRender(): void },
139
141
  private done: (result: McpPanelResult) => void,
140
- options: { noticeLines?: string[]; authOnly?: boolean } = {},
142
+ options: { noticeLines?: string[]; authOnly?: boolean; keybindings?: PanelKeybindings } = {},
141
143
  ) {
142
144
  this.tui = tui;
143
145
  this.noticeLines = options.noticeLines ?? [];
144
146
  this.authOnly = options.authOnly === true;
147
+ this.keys = createPanelKeys(options.keybindings);
145
148
  this.prefix = config.settings?.toolPrefix ?? "server";
146
149
 
147
150
  for (const [serverName, definition] of Object.entries(config.mcpServers)) {
@@ -318,7 +321,7 @@ class McpPanel {
318
321
 
319
322
  // Modal description search mode
320
323
  if (this.descSearchActive) {
321
- if (matchesKey(data, "escape") || matchesKey(data, "return")) {
324
+ if (matchesKey(data, "escape") || this.keys.selectConfirm(data)) {
322
325
  this.descSearchActive = false;
323
326
  this.descQuery = "";
324
327
  this.rebuildVisibleItems();
@@ -333,8 +336,8 @@ class McpPanel {
333
336
  }
334
337
  return;
335
338
  }
336
- if (matchesKey(data, "up")) { this.moveCursor(-1); return; }
337
- if (matchesKey(data, "down")) { this.moveCursor(1); return; }
339
+ if (this.keys.selectUp(data)) { this.moveCursor(-1); return; }
340
+ if (this.keys.selectDown(data)) { this.moveCursor(1); return; }
338
341
  if (matchesKey(data, "space")) {
339
342
  // Toggle even while in desc search
340
343
  const item = this.visibleItems[this.cursorIndex];
@@ -367,8 +370,8 @@ class McpPanel {
367
370
  return;
368
371
  }
369
372
 
370
- if (matchesKey(data, "up")) { this.moveCursor(-1); return; }
371
- if (matchesKey(data, "down")) { this.moveCursor(1); return; }
373
+ if (this.keys.selectUp(data)) { this.moveCursor(-1); return; }
374
+ if (this.keys.selectDown(data)) { this.moveCursor(1); return; }
372
375
 
373
376
  if (matchesKey(data, "space")) {
374
377
  const item = this.visibleItems[this.cursorIndex];
@@ -376,7 +379,7 @@ class McpPanel {
376
379
  return;
377
380
  }
378
381
 
379
- if (matchesKey(data, "return")) {
382
+ if (this.keys.selectConfirm(data)) {
380
383
  const item = this.visibleItems[this.cursorIndex];
381
384
  if (!item) return;
382
385
  const server = this.servers[item.serverIndex];
@@ -518,7 +521,7 @@ class McpPanel {
518
521
  this.confirmingDiscard = false;
519
522
  return;
520
523
  }
521
- if (matchesKey(data, "return")) {
524
+ if (this.keys.selectConfirm(data)) {
522
525
  if (this.discardSelected === 0) {
523
526
  this.cleanup();
524
527
  this.done({ cancelled: true, changes: new Map() });
@@ -820,7 +823,7 @@ export function createMcpPanel(
820
823
  callbacks: McpPanelCallbacks,
821
824
  tui: { requestRender(): void },
822
825
  done: (result: McpPanelResult) => void,
823
- options?: { noticeLines?: string[]; authOnly?: boolean },
826
+ options?: { noticeLines?: string[]; authOnly?: boolean; keybindings?: PanelKeybindings },
824
827
  ): McpPanel & { dispose(): void } {
825
828
  return new McpPanel(config, cache, provenance, callbacks, tui, done, options ?? {});
826
829
  }
@@ -1,4 +1,5 @@
1
1
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
2
3
  import type { ImportKind } from "./types.ts";
3
4
  import type { ConfigWritePreview, McpDiscoverySummary } from "./config.ts";
4
5
  import type { McpOnboardingState } from "./onboarding-state.ts";
@@ -59,6 +60,7 @@ export interface SetupPanelCallbacks {
59
60
  export interface SetupPanelOptions {
60
61
  mode: "empty" | "setup";
61
62
  onboardingState: McpOnboardingState;
63
+ keybindings?: PanelKeybindings;
62
64
  }
63
65
 
64
66
  type Screen = "empty" | "setup" | "imports" | "paths";
@@ -89,6 +91,7 @@ export class McpSetupPanel {
89
91
  private notice: { text: string; tone: "success" | "warning" | "muted" } | null = null;
90
92
  private tui: { requestRender(): void };
91
93
  private t = DEFAULT_THEME;
94
+ private keys: PanelKeys;
92
95
  private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
93
96
  private static readonly INACTIVITY_MS = 60_000;
94
97
 
@@ -100,6 +103,7 @@ export class McpSetupPanel {
100
103
  private done: () => void,
101
104
  ) {
102
105
  this.tui = tui;
106
+ this.keys = createPanelKeys(options.keybindings);
103
107
  this.screen = options.mode;
104
108
  for (const entry of discovery.imports) {
105
109
  this.selectedImports.add(entry.kind);
@@ -191,17 +195,17 @@ export class McpSetupPanel {
191
195
  }
192
196
 
193
197
  const actions = this.getActions();
194
- if (matchesKey(data, "up")) {
198
+ if (this.keys.selectUp(data)) {
195
199
  this.actionCursor = Math.max(0, this.actionCursor - 1);
196
200
  this.tui.requestRender();
197
201
  return;
198
202
  }
199
- if (matchesKey(data, "down")) {
203
+ if (this.keys.selectDown(data)) {
200
204
  this.actionCursor = Math.min(actions.length - 1, this.actionCursor + 1);
201
205
  this.tui.requestRender();
202
206
  return;
203
207
  }
204
- if (matchesKey(data, "return")) {
208
+ if (this.keys.selectConfirm(data)) {
205
209
  const selected = this.getSelectedAction();
206
210
  if (selected) void this.runAction(selected.id);
207
211
  }
@@ -209,12 +213,12 @@ export class McpSetupPanel {
209
213
 
210
214
  private handleImportsInput(data: string): void {
211
215
  const imports = this.discovery.imports;
212
- if (matchesKey(data, "up")) {
216
+ if (this.keys.selectUp(data)) {
213
217
  this.importCursor = Math.max(0, this.importCursor - 1);
214
218
  this.tui.requestRender();
215
219
  return;
216
220
  }
217
- if (matchesKey(data, "down")) {
221
+ if (this.keys.selectDown(data)) {
218
222
  this.importCursor = Math.min(imports.length - 1, this.importCursor + 1);
219
223
  this.tui.requestRender();
220
224
  return;
@@ -230,24 +234,24 @@ export class McpSetupPanel {
230
234
  this.tui.requestRender();
231
235
  return;
232
236
  }
233
- if (matchesKey(data, "return")) {
237
+ if (this.keys.selectConfirm(data)) {
234
238
  void this.applySelectedImports();
235
239
  }
236
240
  }
237
241
 
238
242
  private handlePathsInput(data: string): void {
239
243
  const paths = this.getDetectedPaths();
240
- if (matchesKey(data, "up")) {
244
+ if (this.keys.selectUp(data)) {
241
245
  this.pathCursor = Math.max(0, this.pathCursor - 1);
242
246
  this.tui.requestRender();
243
247
  return;
244
248
  }
245
- if (matchesKey(data, "down")) {
249
+ if (this.keys.selectDown(data)) {
246
250
  this.pathCursor = Math.min(paths.length - 1, this.pathCursor + 1);
247
251
  this.tui.requestRender();
248
252
  return;
249
253
  }
250
- if (matchesKey(data, "return")) {
254
+ if (this.keys.selectConfirm(data)) {
251
255
  const selected = paths[this.pathCursor];
252
256
  if (!selected) return;
253
257
  void this.runBusy(async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,6 +54,7 @@
54
54
  "config.ts",
55
55
  "server-manager.ts",
56
56
  "sampling-handler.ts",
57
+ "elicitation-handler.ts",
57
58
  "tool-registrar.ts",
58
59
  "tool-result-renderer.ts",
59
60
  "resource-tools.ts",
@@ -71,6 +72,7 @@
71
72
  "mcp-callback-server.ts",
72
73
  "mcp-auth-flow.ts",
73
74
  "mcp-panel.ts",
75
+ "panel-keys.ts",
74
76
  "logger.ts",
75
77
  "errors.ts",
76
78
  "app-bridge.bundle.js",
@@ -93,7 +95,7 @@
93
95
  "zod": "^3.25.0 || ^4.0.0"
94
96
  },
95
97
  "devDependencies": {
96
- "@earendil-works/pi-coding-agent": "^0.74.0",
98
+ "@earendil-works/pi-coding-agent": "^0.79.1",
97
99
  "@types/bun": "^1.0.0",
98
100
  "@types/node": "^20.0.0",
99
101
  "@types/open": "^6.2.1",
package/panel-keys.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { matchesKey } from "@earendil-works/pi-tui";
2
+
3
+ /** The `tui.select.*` keybinding ids the adapter panels resolve. */
4
+ export type PanelSelectKeybinding =
5
+ | "tui.select.up"
6
+ | "tui.select.down"
7
+ | "tui.select.confirm";
8
+
9
+ /** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
10
+ export interface PanelKeybindings {
11
+ matches(data: string, keybinding: PanelSelectKeybinding): boolean;
12
+ }
13
+
14
+ /**
15
+ * Key matchers for list navigation: user's `tui.select.*` bindings when a
16
+ * manager is provided, otherwise the previous hardcoded defaults.
17
+ */
18
+ export interface PanelKeys {
19
+ selectUp(data: string): boolean;
20
+ selectDown(data: string): boolean;
21
+ selectConfirm(data: string): boolean;
22
+ }
23
+
24
+ export function createPanelKeys(keybindings?: PanelKeybindings): PanelKeys {
25
+ if (keybindings) {
26
+ return {
27
+ selectUp: (data) => keybindings.matches(data, "tui.select.up"),
28
+ selectDown: (data) => keybindings.matches(data, "tui.select.down"),
29
+ selectConfirm: (data) => keybindings.matches(data, "tui.select.confirm"),
30
+ };
31
+ }
32
+ return {
33
+ selectUp: (data) => matchesKey(data, "up"),
34
+ selectDown: (data) => matchesKey(data, "down"),
35
+ selectConfirm: (data) => matchesKey(data, "return"),
36
+ };
37
+ }