@esaio/esa-mcp-server 0.1.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.
Files changed (80) hide show
  1. package/.claude/settings.local.json +23 -0
  2. package/.dockerignore +36 -0
  3. package/.envrc +2 -0
  4. package/.github/dependabot.yml +18 -0
  5. package/.github/workflows/docker-publish.yml +120 -0
  6. package/.github/workflows/main.yml +41 -0
  7. package/.node-version +1 -0
  8. package/CLAUDE.md +94 -0
  9. package/Dockerfile +34 -0
  10. package/LICENSE +7 -0
  11. package/README.en.md +139 -0
  12. package/README.md +139 -0
  13. package/bin/index.js +30 -0
  14. package/biome.json +57 -0
  15. package/package.json +48 -0
  16. package/src/__tests__/fixtures/mock-comment.ts +90 -0
  17. package/src/__tests__/fixtures/mock-post.ts +79 -0
  18. package/src/__tests__/index.test.ts +209 -0
  19. package/src/api_client/__tests__/index.test.ts +149 -0
  20. package/src/api_client/__tests__/middleware.test.ts +119 -0
  21. package/src/api_client/__tests__/with-context.test.ts +98 -0
  22. package/src/api_client/index.ts +29 -0
  23. package/src/api_client/middleware.ts +21 -0
  24. package/src/api_client/with-context.ts +26 -0
  25. package/src/config/__tests__/index.test.ts +65 -0
  26. package/src/config/index.ts +20 -0
  27. package/src/context/mcp-context.ts +1 -0
  28. package/src/context/stdio-context.ts +6 -0
  29. package/src/errors/missing-team-name-error.ts +8 -0
  30. package/src/formatters/__tests__/mcp-response.test.ts +106 -0
  31. package/src/formatters/mcp-response.ts +95 -0
  32. package/src/generated/api-types.ts +2691 -0
  33. package/src/i18n/__tests__/index.test.ts +53 -0
  34. package/src/i18n/index.ts +39 -0
  35. package/src/index.ts +47 -0
  36. package/src/locales/en.json +13 -0
  37. package/src/locales/ja.json +13 -0
  38. package/src/prompts/__tests__/index.test.ts +47 -0
  39. package/src/prompts/__tests__/summarize-post.test.ts +291 -0
  40. package/src/prompts/index.ts +21 -0
  41. package/src/prompts/summarize-post.ts +94 -0
  42. package/src/resources/__tests__/index.test.ts +49 -0
  43. package/src/resources/__tests__/recent-posts-list.test.ts +91 -0
  44. package/src/resources/__tests__/recent-posts.test.ts +270 -0
  45. package/src/resources/index.ts +33 -0
  46. package/src/resources/recent-posts-list.ts +22 -0
  47. package/src/resources/recent-posts.ts +45 -0
  48. package/src/schemas/team-name-schema.ts +19 -0
  49. package/src/tools/__tests__/categories.test.ts +226 -0
  50. package/src/tools/__tests__/comments.test.ts +970 -0
  51. package/src/tools/__tests__/helps.test.ts +222 -0
  52. package/src/tools/__tests__/index.test.ts +47 -0
  53. package/src/tools/__tests__/post-actions.test.ts +445 -0
  54. package/src/tools/__tests__/posts.test.ts +917 -0
  55. package/src/tools/__tests__/search.test.ts +339 -0
  56. package/src/tools/__tests__/teams.test.ts +615 -0
  57. package/src/tools/categories.ts +93 -0
  58. package/src/tools/comments.ts +258 -0
  59. package/src/tools/helps.ts +50 -0
  60. package/src/tools/index.ts +324 -0
  61. package/src/tools/post-actions.ts +132 -0
  62. package/src/tools/posts.ts +179 -0
  63. package/src/tools/search.ts +98 -0
  64. package/src/tools/teams.ts +157 -0
  65. package/src/transformers/__tests__/category-transformer.test.ts +161 -0
  66. package/src/transformers/__tests__/comment-transformer.test.ts +129 -0
  67. package/src/transformers/__tests__/post-name-normalizer.test.ts +53 -0
  68. package/src/transformers/__tests__/post-transformer.test.ts +70 -0
  69. package/src/transformers/__tests__/query-normalizer.test.ts +98 -0
  70. package/src/transformers/__tests__/team-name-normalizer.test.ts +21 -0
  71. package/src/transformers/category-transformer.ts +36 -0
  72. package/src/transformers/comment-transformer.ts +34 -0
  73. package/src/transformers/post-name-normalizer.ts +30 -0
  74. package/src/transformers/post-transformer.ts +38 -0
  75. package/src/transformers/query-normalizer.ts +36 -0
  76. package/src/transformers/team-name-normalizer.ts +7 -0
  77. package/tsconfig.build.json +4 -0
  78. package/tsconfig.json +30 -0
  79. package/tsdown.config.ts +13 -0
  80. package/vitest.config.ts +24 -0
@@ -0,0 +1,132 @@
1
+ import { z } from "zod";
2
+ import type { createEsaClient } from "../api_client/index.js";
3
+ import { MissingTeamNameError } from "../errors/missing-team-name-error.js";
4
+ import {
5
+ formatToolError,
6
+ formatToolResponse,
7
+ } from "../formatters/mcp-response.js";
8
+ import type { components } from "../generated/api-types.js";
9
+ import { createSchemaWithTeamName } from "../schemas/team-name-schema.js";
10
+ import { normalizeTeamName } from "../transformers/team-name-normalizer.js";
11
+ import { createPost, updatePost } from "./posts.js";
12
+
13
+ export const archivePostSchema = createSchemaWithTeamName({
14
+ postNumber: z.number().describe("The post number to archive"),
15
+ message: z.string().optional().describe("Archive message for the post"),
16
+ });
17
+
18
+ export async function archivePost(
19
+ client: ReturnType<typeof createEsaClient>,
20
+ args: z.infer<typeof archivePostSchema>,
21
+ ) {
22
+ try {
23
+ if (!args.teamName) {
24
+ throw new MissingTeamNameError();
25
+ }
26
+ const { data, error, response } = await client.GET(
27
+ "/v1/teams/{team_name}/posts/{post_number}",
28
+ {
29
+ params: {
30
+ path: { team_name: args.teamName, post_number: args.postNumber },
31
+ },
32
+ },
33
+ );
34
+
35
+ if (error || !response.ok) {
36
+ return formatToolError(error || response.status);
37
+ }
38
+
39
+ const post: components["schemas"]["Post"] = data;
40
+ const currentCategory = post.category || "";
41
+
42
+ if (currentCategory.startsWith("Archived/")) {
43
+ return formatToolResponse({
44
+ message: "Post is already archived",
45
+ category: currentCategory,
46
+ });
47
+ }
48
+
49
+ const archivedCategory =
50
+ currentCategory === "" ? "Archived" : `Archived/${currentCategory}`;
51
+
52
+ return await updatePost(client, {
53
+ teamName: args.teamName,
54
+ postNumber: args.postNumber,
55
+ category: archivedCategory,
56
+ message: args.message || "Archive post",
57
+ });
58
+ } catch (error) {
59
+ return formatToolError(error);
60
+ }
61
+ }
62
+
63
+ export const shipPostSchema = createSchemaWithTeamName({
64
+ postNumber: z.number().describe("The post number to ship"),
65
+ });
66
+
67
+ export async function shipPost(
68
+ client: ReturnType<typeof createEsaClient>,
69
+ args: z.infer<typeof shipPostSchema>,
70
+ ) {
71
+ try {
72
+ if (!args.teamName) {
73
+ throw new MissingTeamNameError();
74
+ }
75
+ return await updatePost(client, {
76
+ teamName: args.teamName,
77
+ postNumber: args.postNumber,
78
+ wip: false,
79
+ message: "Ship It!",
80
+ });
81
+ } catch (error) {
82
+ return formatToolError(error);
83
+ }
84
+ }
85
+
86
+ export const duplicatePostSchema = createSchemaWithTeamName({
87
+ postNumber: z
88
+ .number()
89
+ .describe("The source post number to prepare for duplication"),
90
+ targetTeamName: z
91
+ .string()
92
+ .optional()
93
+ .describe("The name of the esa team")
94
+ .transform((val) => (val ? normalizeTeamName(val) : undefined)),
95
+ });
96
+
97
+ export async function duplicatePost(
98
+ client: ReturnType<typeof createEsaClient>,
99
+ args: z.infer<typeof duplicatePostSchema>,
100
+ ) {
101
+ try {
102
+ if (!args.teamName) {
103
+ throw new MissingTeamNameError();
104
+ }
105
+ const { data, error, response } = await client.GET(
106
+ "/v1/teams/{team_name}/posts/new",
107
+ {
108
+ params: {
109
+ path: { team_name: args.teamName },
110
+ query: {
111
+ parent_post_id: args.postNumber,
112
+ },
113
+ },
114
+ },
115
+ );
116
+
117
+ if (error || !response.ok) {
118
+ return formatToolError(error || response.status);
119
+ }
120
+
121
+ const postNew = data.post as components["schemas"]["PostNew"];
122
+
123
+ return createPost(client, {
124
+ teamName: args.targetTeamName || args.teamName,
125
+ name: postNew.name,
126
+ bodyMd: postNew.body_md,
127
+ wip: true,
128
+ });
129
+ } catch (error) {
130
+ return formatToolError(error);
131
+ }
132
+ }
@@ -0,0 +1,179 @@
1
+ import { z } from "zod";
2
+ import type { createEsaClient } from "../api_client/index.js";
3
+ import { MissingTeamNameError } from "../errors/missing-team-name-error.js";
4
+ import {
5
+ formatToolError,
6
+ formatToolResponse,
7
+ } from "../formatters/mcp-response.js";
8
+ import type { components } from "../generated/api-types.js";
9
+ import { createSchemaWithTeamName } from "../schemas/team-name-schema.js";
10
+ import { normalizePostName } from "../transformers/post-name-normalizer.js";
11
+ import { transformPost } from "../transformers/post-transformer.js";
12
+
13
+ export const getPostSchema = createSchemaWithTeamName({
14
+ postNumber: z.number().describe("The post number to retrieve"),
15
+ include: z
16
+ .enum(["comments"])
17
+ .optional()
18
+ .describe("Specify 'comments' to include comments in the response"),
19
+ });
20
+
21
+ export async function getPost(
22
+ client: ReturnType<typeof createEsaClient>,
23
+ args: z.infer<typeof getPostSchema>,
24
+ ) {
25
+ try {
26
+ if (!args.teamName) {
27
+ throw new MissingTeamNameError();
28
+ }
29
+ const { data, error, response } = await client.GET(
30
+ "/v1/teams/{team_name}/posts/{post_number}",
31
+ {
32
+ params: {
33
+ path: { team_name: args.teamName, post_number: args.postNumber },
34
+ query: {
35
+ include: args.include,
36
+ },
37
+ },
38
+ },
39
+ );
40
+
41
+ if (error || !response.ok) {
42
+ return formatToolError(error || response.status);
43
+ }
44
+ const post: components["schemas"]["Post"] = data;
45
+ const transformed = transformPost(post);
46
+
47
+ return formatToolResponse(transformed);
48
+ } catch (error) {
49
+ return formatToolError(error);
50
+ }
51
+ }
52
+
53
+ export const createPostSchema = createSchemaWithTeamName({
54
+ name: z.string().describe("The post name (title)"),
55
+ bodyMd: z.string().optional().describe("The post content in Markdown format"),
56
+ tags: z.array(z.string()).optional().describe("Tags for the post"),
57
+ category: z.string().optional().describe("Category path (e.g., 'dev/docs')"),
58
+ wip: z
59
+ .boolean()
60
+ .default(true)
61
+ .describe(
62
+ "Whether the post is Work In Progress. Set to false to ship it (mark as complete and ready to be published)",
63
+ ),
64
+ message: z.string().optional().describe("Update message for the post"),
65
+ });
66
+
67
+ export async function createPost(
68
+ client: ReturnType<typeof createEsaClient>,
69
+ args: z.infer<typeof createPostSchema>,
70
+ ) {
71
+ try {
72
+ if (!args.teamName) {
73
+ throw new MissingTeamNameError();
74
+ }
75
+ const { name, category } = normalizePostName(args.name, args.category);
76
+
77
+ const { data, error, response } = await client.POST(
78
+ "/v1/teams/{team_name}/posts",
79
+ {
80
+ params: {
81
+ path: { team_name: args.teamName },
82
+ },
83
+ body: {
84
+ post: {
85
+ name: name,
86
+ body_md: args.bodyMd,
87
+ tags: args.tags,
88
+ category: category,
89
+ wip: args.wip,
90
+ message: args.message,
91
+ } as components["schemas"]["PostCreateInput"],
92
+ },
93
+ },
94
+ );
95
+
96
+ if (error || !response.ok) {
97
+ return formatToolError(error || response.status);
98
+ }
99
+
100
+ const post: components["schemas"]["Post"] = data;
101
+ const transformed = transformPost(post);
102
+
103
+ return formatToolResponse(transformed);
104
+ } catch (error) {
105
+ return formatToolError(error);
106
+ }
107
+ }
108
+
109
+ export const updatePostSchema = createSchemaWithTeamName({
110
+ postNumber: z.number().describe("The post number to update"),
111
+ name: z.string().optional().describe("The post name (title)"),
112
+ bodyMd: z.string().optional().describe("The post content in Markdown format"),
113
+ tags: z.array(z.string()).optional().describe("Tags for the post"),
114
+ category: z.string().optional().describe("Category path (e.g., 'dev/docs')"),
115
+ wip: z
116
+ .boolean()
117
+ .optional()
118
+ .describe(
119
+ "Whether the post is Work In Progress. Set to false to ship it (mark as complete and ready to be published)",
120
+ ),
121
+ message: z.string().optional().describe("Update message for the post"),
122
+ originalRevision: z
123
+ .object({
124
+ bodyMd: z.string(),
125
+ number: z.number(),
126
+ user: z.string(),
127
+ })
128
+ .optional()
129
+ .describe("Original revision to check for conflicts"),
130
+ });
131
+
132
+ export async function updatePost(
133
+ client: ReturnType<typeof createEsaClient>,
134
+ args: z.infer<typeof updatePostSchema>,
135
+ ) {
136
+ try {
137
+ if (!args.teamName) {
138
+ throw new MissingTeamNameError();
139
+ }
140
+ const { name, category } = normalizePostName(args.name, args.category);
141
+
142
+ const { data, error, response } = await client.PATCH(
143
+ "/v1/teams/{team_name}/posts/{post_number}",
144
+ {
145
+ params: {
146
+ path: { team_name: args.teamName, post_number: args.postNumber },
147
+ },
148
+ body: {
149
+ post: {
150
+ name: name,
151
+ body_md: args.bodyMd,
152
+ tags: args.tags,
153
+ category: category,
154
+ wip: args.wip,
155
+ message: args.message,
156
+ original_revision: args.originalRevision
157
+ ? {
158
+ body_md: args.originalRevision.bodyMd,
159
+ number: args.originalRevision.number,
160
+ user: args.originalRevision.user,
161
+ }
162
+ : undefined,
163
+ } as components["schemas"]["PostUpdateInput"],
164
+ },
165
+ },
166
+ );
167
+
168
+ if (error || !response.ok) {
169
+ return formatToolError(error || response.status);
170
+ }
171
+
172
+ const post: components["schemas"]["Post"] = data;
173
+ const transformed = transformPost(post);
174
+
175
+ return formatToolResponse(transformed);
176
+ } catch (error) {
177
+ return formatToolError(error);
178
+ }
179
+ }
@@ -0,0 +1,98 @@
1
+ import { z } from "zod";
2
+ import type { createEsaClient } from "../api_client/index.js";
3
+ import { MissingTeamNameError } from "../errors/missing-team-name-error.js";
4
+ import {
5
+ formatToolError,
6
+ formatToolResponse,
7
+ } from "../formatters/mcp-response.js";
8
+ import type { components } from "../generated/api-types.js";
9
+ import { createSchemaWithTeamName } from "../schemas/team-name-schema.js";
10
+ import { transformPost } from "../transformers/post-transformer.js";
11
+ import { normalizeSearchQuery } from "../transformers/query-normalizer.js";
12
+
13
+ export const searchPostsSchema = createSchemaWithTeamName({
14
+ query: z
15
+ .string()
16
+ .describe(`Search query string. Use specific terms, not wildcards like "*". Empty string returns all posts.
17
+ ## Important Note for Date Queries:
18
+ **WARNING: Do NOT use 'after:', 'before:', 'since:', or 'until:' syntax (these are from GitHub/Gmail/pplog).
19
+ Use esa-specific date syntax: created:>YYYY-MM-DD, created:<YYYY-MM-DD, updated:>YYYY-MM-DD, updated:<YYYY-MM-DD
20
+
21
+ ## Important Note for Relative Date Queries:
22
+ **CRITICAL: Always get today's actual date from the system before processing
23
+ relative date queries (e.g., "today", "yesterday", "last week", "recent").
24
+ When searching, apply these strategies:
25
+ 1. Convert concepts to technical terms (e.g., general descriptions → specific property names, method names, or technical keywords)
26
+ 2. Translate between Japanese and English technical terms (e.g., Japanese concepts → English API/property names)
27
+ 3. Expand to related technical elements (e.g., one concept → multiple implementation approaches, related technologies, or alternative solutions)
28
+ IMPORTANT: Space-separated terms are treated as AND conditions. Use "OR" operator for alternative terms: "word-break OR word-wrap OR overflow-wrap".
29
+ Advanced search: "tag:release", "category:dev", "wip:false", "keyword:API", "title:設計書".
30
+ Category search: "on:category" (posts directly in category), "in:category" (posts in category and subcategories), "on:/" (uncategorized posts).
31
+ For broader results, use OR between related terms rather than listing them with spaces.`)
32
+ .transform(normalizeSearchQuery),
33
+ sort: z
34
+ .enum([
35
+ "updated",
36
+ "created",
37
+ "number",
38
+ "stars",
39
+ "watches",
40
+ "comments",
41
+ "best_match",
42
+ ])
43
+ .optional()
44
+ .describe("Sort key"),
45
+ order: z.enum(["desc", "asc"]).optional().describe("Sort direction"),
46
+ page: z.number().int().positive().optional().describe("Page number"),
47
+ perPage: z
48
+ .number()
49
+ .int()
50
+ .min(1)
51
+ .max(100)
52
+ .optional()
53
+ .describe("Items per page"),
54
+ include: z
55
+ .enum(["comments"])
56
+ .optional()
57
+ .describe("Specify 'comments' to include comments in the response"),
58
+ });
59
+
60
+ export async function searchPosts(
61
+ client: ReturnType<typeof createEsaClient>,
62
+ args: z.infer<typeof searchPostsSchema>,
63
+ ) {
64
+ try {
65
+ if (!args.teamName) {
66
+ throw new MissingTeamNameError();
67
+ }
68
+
69
+ const { data, error, response } = await client.GET(
70
+ "/v1/teams/{team_name}/posts",
71
+ {
72
+ params: {
73
+ path: { team_name: args.teamName },
74
+ query: {
75
+ q: args.query,
76
+ sort: args.sort,
77
+ order: args.order,
78
+ page: args.page,
79
+ per_page: args.perPage,
80
+ include: args.include,
81
+ },
82
+ },
83
+ },
84
+ );
85
+
86
+ if (error || !response.ok) {
87
+ return formatToolError(error || response.status);
88
+ }
89
+ const posts: components["schemas"]["Post"][] = data.posts;
90
+ const transformed = posts.map((post) =>
91
+ transformPost(post, { truncateBody: 500 }),
92
+ );
93
+
94
+ return formatToolResponse(transformed);
95
+ } catch (error) {
96
+ return formatToolError(error);
97
+ }
98
+ }
@@ -0,0 +1,157 @@
1
+ import { z } from "zod";
2
+ import type { createEsaClient } from "../api_client/index.js";
3
+ import { MissingTeamNameError } from "../errors/missing-team-name-error.js";
4
+ import {
5
+ formatToolError,
6
+ formatToolResponse,
7
+ } from "../formatters/mcp-response.js";
8
+ import type { components } from "../generated/api-types.js";
9
+ import { createSchemaWithTeamName } from "../schemas/team-name-schema.js";
10
+
11
+ export const getTeamsSchema = z.object({
12
+ page: z.number().optional().describe("Page number (starts from 1)"),
13
+ perPage: z.number().optional().describe("Number of items per page"),
14
+ role: z.enum(["member", "owner"]).optional().describe("Filter by role"),
15
+ });
16
+
17
+ export async function getTeams(
18
+ client: ReturnType<typeof createEsaClient>,
19
+ args: z.infer<typeof getTeamsSchema> = {},
20
+ ) {
21
+ try {
22
+ const { data, error, response } = await client.GET("/v1/teams", {
23
+ params: {
24
+ query: {
25
+ page: args.page,
26
+ per_page: args.perPage,
27
+ role: args.role,
28
+ },
29
+ },
30
+ });
31
+
32
+ if (error || !response.ok) {
33
+ return formatToolError(error || response.status);
34
+ }
35
+
36
+ const transformed = {
37
+ ...data,
38
+ teams: data.teams?.map((team: components["schemas"]["Team"]) => ({
39
+ url: team.url,
40
+ name: team.name,
41
+ description: team.description,
42
+ })),
43
+ };
44
+
45
+ return formatToolResponse(transformed);
46
+ } catch (error) {
47
+ return formatToolError(error);
48
+ }
49
+ }
50
+
51
+ export const getTeamStatsSchema = createSchemaWithTeamName({});
52
+
53
+ export async function getTeamStats(
54
+ client: ReturnType<typeof createEsaClient>,
55
+ args: z.infer<typeof getTeamStatsSchema>,
56
+ ) {
57
+ try {
58
+ if (!args.teamName) {
59
+ throw new MissingTeamNameError();
60
+ }
61
+ const { data, error, response } = await client.GET(
62
+ `/v1/teams/{team_name}/stats`,
63
+ {
64
+ params: {
65
+ path: { team_name: args.teamName },
66
+ },
67
+ },
68
+ );
69
+
70
+ if (error || !response.ok) {
71
+ return formatToolError(error || response.status);
72
+ }
73
+
74
+ return formatToolResponse(data);
75
+ } catch (error) {
76
+ return formatToolError(error);
77
+ }
78
+ }
79
+
80
+ export const getTeamTagsSchema = createSchemaWithTeamName({
81
+ page: z.number().optional().describe("Page number (starts from 1)"),
82
+ perPage: z.number().optional().describe("Number of items per page"),
83
+ });
84
+
85
+ export async function getTeamTags(
86
+ client: ReturnType<typeof createEsaClient>,
87
+ args: z.infer<typeof getTeamTagsSchema>,
88
+ ) {
89
+ try {
90
+ if (!args.teamName) {
91
+ throw new MissingTeamNameError();
92
+ }
93
+ const { data, error, response } = await client.GET(
94
+ `/v1/teams/{team_name}/tags`,
95
+ {
96
+ params: {
97
+ path: { team_name: args.teamName },
98
+ query: {
99
+ page: args.page,
100
+ per_page: args.perPage,
101
+ },
102
+ },
103
+ },
104
+ );
105
+
106
+ if (error || !response.ok) {
107
+ return formatToolError(error || response.status);
108
+ }
109
+
110
+ return formatToolResponse(data);
111
+ } catch (error) {
112
+ return formatToolError(error);
113
+ }
114
+ }
115
+
116
+ export const getTeamMembersSchema = createSchemaWithTeamName({
117
+ page: z.number().optional().describe("Page number (starts from 1)"),
118
+ perPage: z.number().optional().describe("Number of items per page"),
119
+ sort: z
120
+ .enum(["posts_count", "joined", "last_accessed"])
121
+ .optional()
122
+ .describe("Sort criteria"),
123
+ order: z.enum(["desc", "asc"]).optional().describe("Sort order"),
124
+ });
125
+
126
+ export async function getTeamMembers(
127
+ client: ReturnType<typeof createEsaClient>,
128
+ args: z.infer<typeof getTeamMembersSchema>,
129
+ ) {
130
+ try {
131
+ if (!args.teamName) {
132
+ throw new MissingTeamNameError();
133
+ }
134
+ const { data, error, response } = await client.GET(
135
+ `/v1/teams/{team_name}/members`,
136
+ {
137
+ params: {
138
+ path: { team_name: args.teamName },
139
+ query: {
140
+ page: args.page,
141
+ per_page: args.perPage,
142
+ sort: args.sort,
143
+ order: args.order,
144
+ },
145
+ },
146
+ },
147
+ );
148
+
149
+ if (error || !response.ok) {
150
+ return formatToolError(error || response.status);
151
+ }
152
+
153
+ return formatToolResponse(data);
154
+ } catch (error) {
155
+ return formatToolError(error);
156
+ }
157
+ }