pi-zai-tools 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ ### Added
8
+ - `zai_web_search` tool backed by Z.AI Web Search MCP
9
+ - `zai_web_reader` tool backed by Z.AI Web Reader MCP
10
+ - `zai_zread_search_doc` tool backed by Zread MCP
11
+ - `zai_zread_get_repo_structure` tool backed by Zread MCP
12
+ - `zai_zread_read_file` tool backed by Zread MCP
13
+ - env-based module enable/disable with `ZAI_ENABLED_MODULES`
14
+ - unit and live integration tests
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Omer Ulusoy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # pi-zai-tools
2
+
3
+ Pi package for Z.AI remote MCP tools.
4
+
5
+ It bundles one pi extension that exposes these capabilities:
6
+ - web search via Z.AI Web Search MCP
7
+ - web page reading via Z.AI Web Reader MCP
8
+ - public GitHub repository docs / structure / file access via Zread MCP
9
+
10
+ ## Included tools
11
+
12
+ - `zai_web_search`
13
+ - `zai_web_reader`
14
+ - `zai_zread_search_doc`
15
+ - `zai_zread_get_repo_structure`
16
+ - `zai_zread_read_file`
17
+
18
+ ## Requirements
19
+
20
+ - [pi](https://github.com/badlogic/pi-mono)
21
+ - a valid Z.AI API key with GLM Coding Plan access
22
+
23
+ ## Install
24
+
25
+ ### With pi
26
+
27
+ ```bash
28
+ pi install npm:pi-zai-tools
29
+ ```
30
+
31
+ ### From a local checkout
32
+
33
+ ```bash
34
+ pi install /absolute/path/to/pi-zai-tools
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ You can copy `examples.env` as a starting point for local development.
40
+
41
+
42
+ ### Required
43
+
44
+ ```bash
45
+ export ZAI_API_KEY=your_api_key
46
+ ```
47
+
48
+ ### Optional
49
+
50
+ Enable only selected modules:
51
+
52
+ ```bash
53
+ export ZAI_ENABLED_MODULES=search,reader,zread
54
+ ```
55
+
56
+ Defaults to all modules when omitted.
57
+
58
+ Override timeout and base URL:
59
+
60
+ ```bash
61
+ export ZAI_TIMEOUT_MS=30000
62
+ export ZAI_BASE_URL=https://api.z.ai
63
+ ```
64
+
65
+ ## Module mapping
66
+
67
+ - `search` → `zai_web_search`
68
+ - `reader` → `zai_web_reader`
69
+ - `zread` → `zai_zread_search_doc`, `zai_zread_get_repo_structure`, `zai_zread_read_file`
70
+
71
+ ## Usage examples
72
+
73
+ ### Search the web
74
+
75
+ - “Search for recent React Server Components caching guidance”
76
+ - “Find best practices for Python async retry strategies”
77
+
78
+ ### Read a page
79
+
80
+ - “Read https://example.com and summarize it”
81
+ - “Fetch this documentation page and list the migration steps”
82
+
83
+ ### Research a GitHub repo with Zread
84
+
85
+ - “Search docs in vercel/ai for installation steps”
86
+ - “Show me the structure of vercel/ai”
87
+ - “Read package.json from vercel/ai”
88
+
89
+ ## Tool parameters
90
+
91
+ ### `zai_web_search`
92
+ - `query: string`
93
+ - `count?: number`
94
+
95
+ ### `zai_web_reader`
96
+ - `url: string`
97
+
98
+ ### `zai_zread_search_doc`
99
+ - `repo: string` (`owner/repo`)
100
+ - `query: string`
101
+
102
+ ### `zai_zread_get_repo_structure`
103
+ - `repo: string` (`owner/repo`)
104
+
105
+ ### `zai_zread_read_file`
106
+ - `repo: string` (`owner/repo`)
107
+ - `path: string`
108
+
109
+ ## Troubleshooting
110
+
111
+ ### Missing API key
112
+
113
+ If a tool reports that `ZAI_API_KEY` is missing, export it in your shell before launching pi.
114
+
115
+ ### Invalid token / auth failure
116
+
117
+ Check that:
118
+ - the token is correct
119
+ - the token is active
120
+ - the token has quota left
121
+
122
+ ### Timeout
123
+
124
+ Increase:
125
+
126
+ ```bash
127
+ export ZAI_TIMEOUT_MS=60000
128
+ ```
129
+
130
+ ### Empty or weak results
131
+
132
+ - try a broader search query
133
+ - verify the target URL is public
134
+ - verify the target repository is public and spelled as `owner/repo`
135
+
136
+ ## Validation status
137
+
138
+ This package includes:
139
+ - unit tests for config, truncation, service fallback behavior, and extension registration
140
+ - live integration tests against Z.AI MCP endpoints when `ZAI_API_KEY` is available
141
+
142
+ ## Quota note
143
+
144
+ Quota is controlled by your Z.AI plan, not by this package.
145
+
146
+ ## Security note
147
+
148
+ Do not hardcode API keys in the package or your repo. Prefer environment variables.
149
+
150
+ ## License
151
+
152
+ MIT
package/examples.env ADDED
@@ -0,0 +1,7 @@
1
+ # Required
2
+ ZAI_API_KEY=your_api_key
3
+
4
+ # Optional
5
+ # ZAI_ENABLED_MODULES=search,reader,zread
6
+ # ZAI_TIMEOUT_MS=30000
7
+ # ZAI_BASE_URL=https://api.z.ai
@@ -0,0 +1,41 @@
1
+ import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
2
+ import { MCP_SERVER_PATHS } from '../src/constants.ts';
3
+ import { loadConfig } from '../src/config.ts';
4
+ import { createRemoteMcpClient } from '../src/client/remote-mcp.ts';
5
+ import { createWebReaderService } from '../src/services/web-reader.ts';
6
+ import { createWebSearchService } from '../src/services/web-search.ts';
7
+ import { createZreadService } from '../src/services/zread.ts';
8
+ import { createWebReaderTool } from '../src/tools/web-reader-tool.ts';
9
+ import { createWebSearchTool } from '../src/tools/web-search-tool.ts';
10
+ import { createZreadGetRepoStructureTool } from '../src/tools/zread-get-repo-structure-tool.ts';
11
+ import { createZreadReadFileTool } from '../src/tools/zread-read-file-tool.ts';
12
+ import { createZreadSearchDocTool } from '../src/tools/zread-search-doc-tool.ts';
13
+ import type { EnvSource } from '../src/types.ts';
14
+
15
+ interface ExtensionOptions {
16
+ env?: EnvSource;
17
+ }
18
+
19
+ export default function zaiToolsExtension(pi: ExtensionAPI, options?: ExtensionOptions) {
20
+ const config = loadConfig(options?.env);
21
+
22
+ if (config.enabledModules.includes('search')) {
23
+ const client = createRemoteMcpClient(config, MCP_SERVER_PATHS.search);
24
+ const service = createWebSearchService(client);
25
+ pi.registerTool(createWebSearchTool(service));
26
+ }
27
+
28
+ if (config.enabledModules.includes('reader')) {
29
+ const client = createRemoteMcpClient(config, MCP_SERVER_PATHS.reader);
30
+ const service = createWebReaderService(client);
31
+ pi.registerTool(createWebReaderTool(service));
32
+ }
33
+
34
+ if (config.enabledModules.includes('zread')) {
35
+ const client = createRemoteMcpClient(config, MCP_SERVER_PATHS.zread);
36
+ const service = createZreadService(client);
37
+ pi.registerTool(createZreadSearchDocTool(service));
38
+ pi.registerTool(createZreadGetRepoStructureTool(service));
39
+ pi.registerTool(createZreadReadFileTool(service));
40
+ }
41
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "pi-zai-tools",
3
+ "version": "0.1.0",
4
+ "description": "Pi package that exposes Z.AI Web Search, Web Reader, and Zread MCP tools.",
5
+ "type": "module",
6
+ "keywords": ["pi-package", "pi", "zai", "mcp", "search", "reader", "zread"],
7
+ "license": "MIT",
8
+ "files": [
9
+ "extensions",
10
+ "src",
11
+ "README.md",
12
+ "LICENSE",
13
+ "CHANGELOG.md",
14
+ "examples.env"
15
+ ],
16
+ "scripts": {
17
+ "test": "vitest run",
18
+ "test:watch": "vitest",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "pi": {
22
+ "extensions": ["./extensions"]
23
+ },
24
+ "peerDependencies": {
25
+ "@mariozechner/pi-coding-agent": "*",
26
+ "@sinclair/typebox": "*"
27
+ },
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.27.1",
30
+ "zod": "^3.25.76"
31
+ },
32
+ "devDependencies": {
33
+ "@mariozechner/pi-coding-agent": "*",
34
+ "@sinclair/typebox": "^0.34.41",
35
+ "typescript": "^5.9.2",
36
+ "vitest": "^3.2.4"
37
+ }
38
+ }
@@ -0,0 +1,47 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import type { McpToolResult, ZaiConfig } from '../types.ts';
4
+ import { AuthError, RemoteMcpError } from '../utils/errors.ts';
5
+
6
+ export interface RemoteMcpClient {
7
+ callTool(toolName: string, args: Record<string, unknown>): Promise<McpToolResult>;
8
+ }
9
+
10
+ export function createRemoteMcpClient(config: ZaiConfig, path: string): RemoteMcpClient {
11
+ return {
12
+ async callTool(toolName, args) {
13
+ if (!config.apiKey) {
14
+ throw new AuthError();
15
+ }
16
+
17
+ const client = new Client({ name: 'pi-zai-tools', version: '0.1.0' });
18
+ const transport = new StreamableHTTPClientTransport(new URL(path, config.baseUrl), {
19
+ requestInit: {
20
+ headers: {
21
+ Authorization: `Bearer ${config.apiKey}`,
22
+ },
23
+ },
24
+ });
25
+
26
+ const timeout = setTimeout(() => transport.close().catch(() => undefined), config.timeoutMs);
27
+
28
+ try {
29
+ await client.connect(transport);
30
+ return (await client.callTool({ name: toolName, arguments: args })) as McpToolResult;
31
+ } catch (error) {
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ if (/401|403|unauthor/i.test(message)) {
34
+ throw new RemoteMcpError('Z.AI authentication failed. Check ZAI_API_KEY.');
35
+ }
36
+ if (/timeout|abort/i.test(message)) {
37
+ throw new RemoteMcpError(`Z.AI request timed out after ${config.timeoutMs}ms.`);
38
+ }
39
+ throw new RemoteMcpError(message);
40
+ } finally {
41
+ clearTimeout(timeout);
42
+ await transport.close().catch(() => undefined);
43
+ await client.close().catch(() => undefined);
44
+ }
45
+ },
46
+ };
47
+ }
package/src/config.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS } from './constants.ts';
2
+ import type { EnvSource, ZaiConfig } from './types.ts';
3
+ import { parseEnabledModules, parseTimeoutMs } from './utils/validation.ts';
4
+
5
+ export function loadConfig(env: EnvSource = process.env): ZaiConfig {
6
+ return {
7
+ apiKey: env.ZAI_API_KEY?.trim() || undefined,
8
+ baseUrl: env.ZAI_BASE_URL?.trim() || DEFAULT_BASE_URL,
9
+ timeoutMs: env.ZAI_TIMEOUT_MS?.trim() ? parseTimeoutMs(env.ZAI_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS,
10
+ enabledModules: parseEnabledModules(env.ZAI_ENABLED_MODULES),
11
+ };
12
+ }
@@ -0,0 +1,18 @@
1
+ export const DEFAULT_BASE_URL = 'https://api.z.ai';
2
+ export const DEFAULT_TIMEOUT_MS = 30_000;
3
+
4
+ export const ENABLED_MODULES = ['search', 'reader', 'zread'] as const;
5
+
6
+ export const MCP_SERVER_PATHS = {
7
+ search: '/api/mcp/web_search_prime/mcp',
8
+ reader: '/api/mcp/web_reader/mcp',
9
+ zread: '/api/mcp/zread/mcp',
10
+ } as const;
11
+
12
+ export const MCP_TOOL_NAMES = {
13
+ webSearch: ['web_search_prime', 'webSearchPrime', 'web_search_prime_web_search_prime'],
14
+ webReader: 'webReader',
15
+ zreadSearchDoc: 'search_doc',
16
+ zreadGetRepoStructure: 'get_repo_structure',
17
+ zreadReadFile: 'read_file',
18
+ } as const;
@@ -0,0 +1,42 @@
1
+ import { MCP_TOOL_NAMES } from '../constants.ts';
2
+ import type { McpCaller, McpToolResult } from '../types.ts';
3
+ import { validateUrl } from '../utils/validation.ts';
4
+
5
+ const ARG_SHAPES = [
6
+ (url: string) => ({ url }),
7
+ (url: string) => ({ target_url: url }),
8
+ (url: string) => ({ webpage_url: url }),
9
+ ];
10
+
11
+ export function createWebReaderService(client: McpCaller) {
12
+ return {
13
+ async read(url: string) {
14
+ const normalizedUrl = validateUrl(url);
15
+ return tryArgumentShapes(client, normalizedUrl);
16
+ },
17
+ };
18
+ }
19
+
20
+ async function tryArgumentShapes(client: McpCaller, url: string) {
21
+ let lastError: unknown;
22
+
23
+ for (const shape of ARG_SHAPES) {
24
+ try {
25
+ const raw = await client.callTool(MCP_TOOL_NAMES.webReader, shape(url));
26
+ return { payload: extractPayload(raw), raw };
27
+ } catch (error) {
28
+ lastError = error;
29
+ }
30
+ }
31
+
32
+ throw lastError;
33
+ }
34
+
35
+ function extractPayload(result: McpToolResult) {
36
+ if (result.structuredContent && typeof result.structuredContent === 'object') {
37
+ return result.structuredContent as Record<string, unknown>;
38
+ }
39
+
40
+ const text = result.content?.find((item) => item.type === 'text');
41
+ return { content: text?.text ?? '' };
42
+ }
@@ -0,0 +1,55 @@
1
+ import { MCP_TOOL_NAMES } from '../constants.ts';
2
+ import type { McpCaller, McpToolResult } from '../types.ts';
3
+ import { assertNonEmptyString } from '../utils/validation.ts';
4
+
5
+ export function createWebSearchService(client: McpCaller) {
6
+ return {
7
+ async search(query: string, count = 5) {
8
+ const normalizedQuery = assertNonEmptyString(query, 'query');
9
+ const normalizedCount = Math.min(Math.max(Math.trunc(count), 1), 10);
10
+ const result = await tryToolNames(client, {
11
+ search_query: normalizedQuery,
12
+ content_size: 'medium',
13
+ });
14
+
15
+ const items = extractItems(result).slice(0, normalizedCount);
16
+ return { items, raw: result };
17
+ },
18
+ };
19
+ }
20
+
21
+ async function tryToolNames(client: McpCaller, args: Record<string, unknown>) {
22
+ let lastError: unknown;
23
+
24
+ for (const toolName of MCP_TOOL_NAMES.webSearch) {
25
+ try {
26
+ return await client.callTool(toolName, args);
27
+ } catch (error) {
28
+ lastError = error;
29
+ }
30
+ }
31
+
32
+ throw lastError;
33
+ }
34
+
35
+ function extractItems(result: McpToolResult): Array<Record<string, unknown>> {
36
+ if (result.structuredContent && typeof result.structuredContent === 'object') {
37
+ const structured = result.structuredContent as Record<string, unknown>;
38
+ const candidates = [structured.items, structured.results, structured.data];
39
+ for (const candidate of candidates) {
40
+ if (Array.isArray(candidate)) {
41
+ return toRecords(candidate);
42
+ }
43
+ }
44
+ }
45
+
46
+ return toRecords(result.content ?? []);
47
+ }
48
+
49
+ function toRecords(values: unknown[]): Array<Record<string, unknown>> {
50
+ return values.filter(isRecord).map((value) => ({ ...value }));
51
+ }
52
+
53
+ function isRecord(value: unknown): value is Record<string, unknown> {
54
+ return typeof value === 'object' && value !== null;
55
+ }
@@ -0,0 +1,90 @@
1
+ import { MCP_TOOL_NAMES } from '../constants.ts';
2
+ import type { McpCaller, McpToolResult } from '../types.ts';
3
+ import { validatePath, validateRepo } from '../utils/validation.ts';
4
+
5
+ const SEARCH_DOC_ARG_SHAPES = [
6
+ (repo: string, query: string) => ({ repo_name: repo, query }),
7
+ (repo: string, query: string) => ({ repo, query }),
8
+ (repo: string, query: string) => ({ repository: repo, query }),
9
+ (repo: string, query: string) => ({ repo, search_query: query }),
10
+ ];
11
+
12
+ const REPO_STRUCTURE_ARG_SHAPES = [
13
+ (repo: string) => ({ repo_name: repo }),
14
+ (repo: string) => ({ repo }),
15
+ (repo: string) => ({ repository: repo }),
16
+ ];
17
+
18
+ const READ_FILE_ARG_SHAPES = [
19
+ (repo: string, path: string) => ({ repo_name: repo, file_path: path }),
20
+ (repo: string, path: string) => ({ repo_name: repo, path }),
21
+ (repo: string, path: string) => ({ repo, path }),
22
+ (repo: string, path: string) => ({ repository: repo, file_path: path }),
23
+ ];
24
+
25
+ export function createZreadService(client: McpCaller) {
26
+ return {
27
+ async searchDoc(repo: string, query: string) {
28
+ const normalizedRepo = validateRepo(repo);
29
+ const normalizedQuery = query.trim();
30
+ const raw = await tryCandidates(client, MCP_TOOL_NAMES.zreadSearchDoc, SEARCH_DOC_ARG_SHAPES.map((shape) => shape(normalizedRepo, normalizedQuery)));
31
+ return { items: extractItems(raw), raw };
32
+ },
33
+ async getRepoStructure(repo: string) {
34
+ const normalizedRepo = validateRepo(repo);
35
+ const raw = await tryCandidates(client, MCP_TOOL_NAMES.zreadGetRepoStructure, REPO_STRUCTURE_ARG_SHAPES.map((shape) => shape(normalizedRepo)));
36
+ return { payload: extractPayload(raw), raw };
37
+ },
38
+ async readFile(repo: string, path: string) {
39
+ const normalizedRepo = validateRepo(repo);
40
+ const normalizedPath = validatePath(path);
41
+ const raw = await tryCandidates(client, MCP_TOOL_NAMES.zreadReadFile, READ_FILE_ARG_SHAPES.map((shape) => shape(normalizedRepo, normalizedPath)));
42
+ return { payload: { ...extractPayload(raw), path: normalizedPath }, raw };
43
+ },
44
+ };
45
+ }
46
+
47
+ async function tryCandidates(client: McpCaller, toolName: string, candidates: Array<Record<string, unknown>>) {
48
+ let lastError: unknown;
49
+
50
+ for (const candidate of candidates) {
51
+ try {
52
+ return await client.callTool(toolName, candidate);
53
+ } catch (error) {
54
+ lastError = error;
55
+ }
56
+ }
57
+
58
+ throw lastError;
59
+ }
60
+
61
+ function extractItems(result: McpToolResult): Array<Record<string, unknown>> {
62
+ if (result.structuredContent && typeof result.structuredContent === 'object') {
63
+ const structured = result.structuredContent as Record<string, unknown>;
64
+ for (const key of ['items', 'results', 'docs', 'data']) {
65
+ const value = structured[key];
66
+ if (Array.isArray(value)) {
67
+ return toRecords(value);
68
+ }
69
+ }
70
+ }
71
+
72
+ return toRecords(result.content ?? []);
73
+ }
74
+
75
+ function toRecords(values: unknown[]): Array<Record<string, unknown>> {
76
+ return values.filter(isRecord).map((value) => ({ ...value }));
77
+ }
78
+
79
+ function extractPayload(result: McpToolResult) {
80
+ if (result.structuredContent && typeof result.structuredContent === 'object') {
81
+ return result.structuredContent as Record<string, unknown>;
82
+ }
83
+
84
+ const text = result.content?.find((item) => item.type === 'text');
85
+ return { content: text?.text ?? '' };
86
+ }
87
+
88
+ function isRecord(value: unknown): value is Record<string, unknown> {
89
+ return typeof value === 'object' && value !== null;
90
+ }
@@ -0,0 +1,21 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import { formatReaderResult } from '../utils/formatting.ts';
3
+
4
+ export function createWebReaderTool(service: { read: (url: string) => Promise<{ payload: Record<string, unknown>; raw: unknown }> }) {
5
+ return {
6
+ name: 'zai_web_reader',
7
+ label: 'Z.AI Web Reader',
8
+ description: 'Read a web page using the Z.AI Web Reader MCP server.',
9
+ parameters: Type.Object({
10
+ url: Type.String({ description: 'The URL to read' }),
11
+ }),
12
+ async execute(_toolCallId: string, params: { url: string }) {
13
+ const result = await service.read(params.url);
14
+ const formatted = formatReaderResult(result.payload);
15
+ return {
16
+ content: [{ type: 'text' as const, text: formatted.summary }],
17
+ details: { ...formatted.details, raw: result.raw },
18
+ };
19
+ },
20
+ };
21
+ }
@@ -0,0 +1,22 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import { formatSearchResults } from '../utils/formatting.ts';
3
+
4
+ export function createWebSearchTool(service: { search: (query: string, count?: number) => Promise<{ items: Array<Record<string, unknown>>; raw: unknown }> }) {
5
+ return {
6
+ name: 'zai_web_search',
7
+ label: 'Z.AI Web Search',
8
+ description: 'Search the web using the Z.AI Web Search MCP server.',
9
+ parameters: Type.Object({
10
+ query: Type.String({ description: 'Search query' }),
11
+ count: Type.Optional(Type.Number({ description: 'Maximum number of results to format', minimum: 1, maximum: 10 })),
12
+ }),
13
+ async execute(_toolCallId: string, params: { query: string; count?: number }) {
14
+ const result = await service.search(params.query, params.count);
15
+ const formatted = formatSearchResults(result.items);
16
+ return {
17
+ content: [{ type: 'text' as const, text: formatted.summary }],
18
+ details: { ...formatted.details, raw: result.raw },
19
+ };
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,21 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import { formatRepoStructure } from '../utils/formatting.ts';
3
+
4
+ export function createZreadGetRepoStructureTool(service: { getRepoStructure: (repo: string) => Promise<{ payload: Record<string, unknown>; raw: unknown }> }) {
5
+ return {
6
+ name: 'zai_zread_get_repo_structure',
7
+ label: 'Z.AI Zread Repo Structure',
8
+ description: 'Get a public GitHub repository structure using Zread.',
9
+ parameters: Type.Object({
10
+ repo: Type.String({ description: 'Repository in owner/repo format' }),
11
+ }),
12
+ async execute(_toolCallId: string, params: { repo: string }) {
13
+ const result = await service.getRepoStructure(params.repo);
14
+ const formatted = formatRepoStructure(result.payload);
15
+ return {
16
+ content: [{ type: 'text' as const, text: formatted.summary }],
17
+ details: { ...formatted.details, raw: result.raw },
18
+ };
19
+ },
20
+ };
21
+ }
@@ -0,0 +1,22 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import { formatFileContent } from '../utils/formatting.ts';
3
+
4
+ export function createZreadReadFileTool(service: { readFile: (repo: string, path: string) => Promise<{ payload: Record<string, unknown>; raw: unknown }> }) {
5
+ return {
6
+ name: 'zai_zread_read_file',
7
+ label: 'Z.AI Zread Read File',
8
+ description: 'Read a file from a public GitHub repository using Zread.',
9
+ parameters: Type.Object({
10
+ repo: Type.String({ description: 'Repository in owner/repo format' }),
11
+ path: Type.String({ description: 'File path within the repository' }),
12
+ }),
13
+ async execute(_toolCallId: string, params: { repo: string; path: string }) {
14
+ const result = await service.readFile(params.repo, params.path);
15
+ const formatted = formatFileContent(result.payload);
16
+ return {
17
+ content: [{ type: 'text' as const, text: formatted.summary }],
18
+ details: { ...formatted.details, raw: result.raw },
19
+ };
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,22 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import { formatSearchResults } from '../utils/formatting.ts';
3
+
4
+ export function createZreadSearchDocTool(service: { searchDoc: (repo: string, query: string) => Promise<{ items: Array<Record<string, unknown>>; raw: unknown }> }) {
5
+ return {
6
+ name: 'zai_zread_search_doc',
7
+ label: 'Z.AI Zread Search Docs',
8
+ description: 'Search documentation and repository knowledge for a public GitHub repo using Zread.',
9
+ parameters: Type.Object({
10
+ repo: Type.String({ description: 'Repository in owner/repo format' }),
11
+ query: Type.String({ description: 'Documentation query' }),
12
+ }),
13
+ async execute(_toolCallId: string, params: { repo: string; query: string }) {
14
+ const result = await service.searchDoc(params.repo, params.query);
15
+ const formatted = formatSearchResults(result.items);
16
+ return {
17
+ content: [{ type: 'text' as const, text: formatted.summary }],
18
+ details: { ...formatted.details, raw: result.raw },
19
+ };
20
+ },
21
+ };
22
+ }
package/src/types.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { ENABLED_MODULES } from './constants.ts';
2
+
3
+ export type EnabledModule = (typeof ENABLED_MODULES)[number];
4
+
5
+ export type EnvSource = Record<string, string | undefined>;
6
+
7
+ export interface ZaiConfig {
8
+ apiKey?: string;
9
+ baseUrl: string;
10
+ timeoutMs: number;
11
+ enabledModules: EnabledModule[];
12
+ }
13
+
14
+ export interface TextContentItem {
15
+ type: 'text';
16
+ text: string;
17
+ }
18
+
19
+ export interface JsonContentItem {
20
+ type: string;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export interface McpToolResult {
25
+ content?: Array<TextContentItem | JsonContentItem>;
26
+ structuredContent?: unknown;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ export interface McpCaller {
31
+ callTool: (toolName: string, args: Record<string, unknown>) => Promise<McpToolResult>;
32
+ }
33
+
34
+ export interface FormattedToolResult {
35
+ summary: string;
36
+ details: Record<string, unknown>;
37
+ }
38
+
@@ -0,0 +1,27 @@
1
+ export class ConfigError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = 'ConfigError';
5
+ }
6
+ }
7
+
8
+ export class ValidationError extends Error {
9
+ constructor(message: string) {
10
+ super(message);
11
+ this.name = 'ValidationError';
12
+ }
13
+ }
14
+
15
+ export class AuthError extends Error {
16
+ constructor(message = 'Missing ZAI_API_KEY. Set it in your environment before using pi-zai-tools.') {
17
+ super(message);
18
+ this.name = 'AuthError';
19
+ }
20
+ }
21
+
22
+ export class RemoteMcpError extends Error {
23
+ constructor(message: string) {
24
+ super(message);
25
+ this.name = 'RemoteMcpError';
26
+ }
27
+ }
@@ -0,0 +1,41 @@
1
+ import type { FormattedToolResult } from '../types.ts';
2
+ import { truncateText } from './truncation.ts';
3
+
4
+ export function formatSearchResults(items: Array<Record<string, unknown>>): FormattedToolResult {
5
+ if (items.length === 0) {
6
+ return { summary: 'No search results returned.', details: { items } };
7
+ }
8
+
9
+ const lines = items.map((item, index) => {
10
+ const title = String(item.title ?? item.name ?? `Result ${index + 1}`);
11
+ const url = String(item.url ?? item.link ?? '');
12
+ const summary = String(item.summary ?? item.snippet ?? '').trim();
13
+ return [`${index + 1}. ${title}`, url && ` ${url}`, summary && ` ${summary}`]
14
+ .filter(Boolean)
15
+ .join('\n');
16
+ });
17
+
18
+ return { summary: lines.join('\n\n'), details: { items } };
19
+ }
20
+
21
+ export function formatReaderResult(payload: Record<string, unknown>): FormattedToolResult {
22
+ const title = String(payload.title ?? 'Untitled page');
23
+ const content = String(payload.content ?? payload.mainContent ?? payload.text ?? '');
24
+ const truncated = truncateText(content, { maxChars: 8_000, label: 'page content' });
25
+ const summary = [`# ${title}`, truncated.text].filter(Boolean).join('\n\n');
26
+ return { summary, details: { ...payload, truncated: truncated.truncated } };
27
+ }
28
+
29
+ export function formatRepoStructure(payload: Record<string, unknown>): FormattedToolResult {
30
+ const structure = String(payload.structure ?? payload.tree ?? payload.content ?? '');
31
+ const truncated = truncateText(structure, { maxChars: 8_000, label: 'repo structure' });
32
+ return { summary: truncated.text || 'No repository structure returned.', details: { ...payload, truncated: truncated.truncated } };
33
+ }
34
+
35
+ export function formatFileContent(payload: Record<string, unknown>): FormattedToolResult {
36
+ const path = String(payload.path ?? 'unknown');
37
+ const content = String(payload.content ?? payload.text ?? '');
38
+ const truncated = truncateText(content, { maxChars: 8_000, label: 'file content' });
39
+ const summary = [`# ${path}`, truncated.text].join('\n\n');
40
+ return { summary, details: { ...payload, truncated: truncated.truncated } };
41
+ }
@@ -0,0 +1,20 @@
1
+ export interface TruncateOptions {
2
+ maxChars: number;
3
+ label?: string;
4
+ }
5
+
6
+ export interface TruncateResult {
7
+ text: string;
8
+ truncated: boolean;
9
+ }
10
+
11
+ export function truncateText(text: string, options: TruncateOptions): TruncateResult {
12
+ if (text.length <= options.maxChars) {
13
+ return { text, truncated: false };
14
+ }
15
+
16
+ const label = options.label ?? 'content';
17
+ const head = text.slice(0, options.maxChars);
18
+ const suffix = `\n\n[${label} truncated: showing ${options.maxChars} of ${text.length} characters]`;
19
+ return { text: `${head}${suffix}`, truncated: true };
20
+ }
@@ -0,0 +1,63 @@
1
+ import { ENABLED_MODULES } from '../constants.ts';
2
+ import type { EnabledModule } from '../types.ts';
3
+ import { ConfigError, ValidationError } from './errors.ts';
4
+
5
+ const enabledModuleSet = new Set<string>(ENABLED_MODULES);
6
+
7
+ export function parseEnabledModules(value?: string): EnabledModule[] {
8
+ if (!value?.trim()) return [...ENABLED_MODULES];
9
+
10
+ return value
11
+ .split(',')
12
+ .map((part) => part.trim())
13
+ .filter(Boolean)
14
+ .map((part) => {
15
+ if (!enabledModuleSet.has(part)) {
16
+ throw new ConfigError(`Unknown ZAI module: ${part}`);
17
+ }
18
+ return part as EnabledModule;
19
+ });
20
+ }
21
+
22
+ export function parseTimeoutMs(value?: string): number {
23
+ if (!value?.trim()) return 30_000;
24
+
25
+ const parsed = Number.parseInt(value, 10);
26
+ if (!Number.isInteger(parsed) || parsed <= 0) {
27
+ throw new ConfigError('ZAI_TIMEOUT_MS must be a positive integer');
28
+ }
29
+ return parsed;
30
+ }
31
+
32
+ export function assertNonEmptyString(value: string, label: string): string {
33
+ if (!value.trim()) {
34
+ throw new ValidationError(`${label} must not be empty`);
35
+ }
36
+ return value.trim();
37
+ }
38
+
39
+ export function validateRepo(repo: string): string {
40
+ const normalized = assertNonEmptyString(repo, 'repo');
41
+ if (!/^[^/\s]+\/[^/\s]+$/.test(normalized)) {
42
+ throw new ValidationError('Invalid repo format. Expected "owner/repo".');
43
+ }
44
+ return normalized;
45
+ }
46
+
47
+ export function validateUrl(url: string): string {
48
+ const normalized = assertNonEmptyString(url, 'url');
49
+ try {
50
+ const parsed = new URL(normalized);
51
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
52
+ throw new ValidationError('URL must start with http:// or https://');
53
+ }
54
+ return parsed.toString();
55
+ } catch (error) {
56
+ if (error instanceof ValidationError) throw error;
57
+ throw new ValidationError('Invalid URL');
58
+ }
59
+ }
60
+
61
+ export function validatePath(path: string): string {
62
+ return assertNonEmptyString(path, 'path');
63
+ }