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 +14 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/examples.env +7 -0
- package/extensions/zai-tools.ts +41 -0
- package/package.json +38 -0
- package/src/client/remote-mcp.ts +47 -0
- package/src/config.ts +12 -0
- package/src/constants.ts +18 -0
- package/src/services/web-reader.ts +42 -0
- package/src/services/web-search.ts +55 -0
- package/src/services/zread.ts +90 -0
- package/src/tools/web-reader-tool.ts +21 -0
- package/src/tools/web-search-tool.ts +22 -0
- package/src/tools/zread-get-repo-structure-tool.ts +21 -0
- package/src/tools/zread-read-file-tool.ts +22 -0
- package/src/tools/zread-search-doc-tool.ts +22 -0
- package/src/types.ts +38 -0
- package/src/utils/errors.ts +27 -0
- package/src/utils/formatting.ts +41 -0
- package/src/utils/truncation.ts +20 -0
- package/src/utils/validation.ts +63 -0
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,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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|