@zyphr-dev/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.
- package/README.md +117 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2315 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/client.ts +25 -0
- package/src/config.test.ts +64 -0
- package/src/config.ts +33 -0
- package/src/index.ts +24 -0
- package/src/integration/quickstart/email.ts +646 -0
- package/src/integration/quickstart/inbox.ts +222 -0
- package/src/integration/quickstart/index.ts +45 -0
- package/src/integration/quickstart/push.ts +216 -0
- package/src/integration/quickstart/quickstart.test.ts +108 -0
- package/src/integration/quickstart/sms.ts +205 -0
- package/src/integration/quickstart/webhook.ts +664 -0
- package/src/integration/quickstart-types.ts +31 -0
- package/src/integration/sdk-snippets.test.ts +63 -0
- package/src/integration/sdk-snippets.ts +248 -0
- package/src/result.test.ts +107 -0
- package/src/result.ts +65 -0
- package/src/schemas.ts +231 -0
- package/src/server.ts +26 -0
- package/src/tools/index.ts +7 -0
- package/src/tools/integration.ts +54 -0
- package/src/tools/send.ts +153 -0
- package/src/tools/subscribers.ts +126 -0
- package/src/tools/templates.ts +87 -0
- package/src/tools/webhooks.ts +82 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// TODO(v0.3): auto-generate quickstart snippets from packages/sdk/<lang>/examples/
|
|
2
|
+
// so docs and MCP output never drift. For v0.2 they are hand-maintained and
|
|
3
|
+
// must mirror apps/docs/docs/channels/<channel>.md and apps/docs/docs/features/webhooks-security.md.
|
|
4
|
+
import type { SdkLanguage } from '../schemas.js';
|
|
5
|
+
|
|
6
|
+
export type QuickstartVariant = 'sdk' | 'webhook-handler';
|
|
7
|
+
|
|
8
|
+
export interface QuickstartFile {
|
|
9
|
+
path: string;
|
|
10
|
+
purpose: string;
|
|
11
|
+
contents: string;
|
|
12
|
+
overwrite: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface QuickstartResult {
|
|
16
|
+
channel: string;
|
|
17
|
+
language: SdkLanguage;
|
|
18
|
+
framework: string | null;
|
|
19
|
+
variant: QuickstartVariant;
|
|
20
|
+
files: QuickstartFile[];
|
|
21
|
+
envVarsNeeded: string[];
|
|
22
|
+
nextSteps: string[];
|
|
23
|
+
docsUrl: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface QuickstartEntry {
|
|
27
|
+
sdk: QuickstartResult;
|
|
28
|
+
frameworks?: Record<string, QuickstartResult>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type QuickstartChannelMap = Partial<Record<SdkLanguage, QuickstartEntry>>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { sdkLanguages, type SdkLanguage } from '../schemas.js';
|
|
3
|
+
import { SDK_INSTALL_TABLE, resolveInstallEntry } from './sdk-snippets.js';
|
|
4
|
+
|
|
5
|
+
const ENV_VAR_PATTERN: Record<SdkLanguage, RegExp> = {
|
|
6
|
+
node: /process\.env/,
|
|
7
|
+
python: /os\.environ/,
|
|
8
|
+
ruby: /ENV/,
|
|
9
|
+
go: /os\.Getenv/,
|
|
10
|
+
php: /getenv/,
|
|
11
|
+
csharp: /GetEnvironmentVariable/,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('SDK_INSTALL_TABLE', () => {
|
|
15
|
+
it.each(sdkLanguages.map((l) => [l]))('returns a complete entry for %s', (language) => {
|
|
16
|
+
const entry = SDK_INSTALL_TABLE[language];
|
|
17
|
+
expect(entry).toBeDefined();
|
|
18
|
+
expect(entry.installCommands.length).toBeGreaterThanOrEqual(1);
|
|
19
|
+
for (const c of entry.installCommands) {
|
|
20
|
+
expect(c.manager).toMatch(/\S/);
|
|
21
|
+
expect(c.command).toMatch(/\S/);
|
|
22
|
+
}
|
|
23
|
+
expect(entry.envVarsNeeded).toContain('ZYPHR_API_KEY');
|
|
24
|
+
expect(entry.initSnippet.init).toMatch(ENV_VAR_PATTERN[language]);
|
|
25
|
+
expect(entry.docsUrl).toMatch(/^https:\/\/docs\.zyphr\.dev\/sdks\//);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('flags non-SDK languages as rest-client', () => {
|
|
29
|
+
expect(SDK_INSTALL_TABLE.python.kind).toBe('rest-client');
|
|
30
|
+
expect(SDK_INSTALL_TABLE.go.kind).toBe('rest-client');
|
|
31
|
+
expect(SDK_INSTALL_TABLE.php.kind).toBe('rest-client');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('flags official SDK languages as sdk', () => {
|
|
35
|
+
expect(SDK_INSTALL_TABLE.node.kind).toBe('sdk');
|
|
36
|
+
expect(SDK_INSTALL_TABLE.ruby.kind).toBe('sdk');
|
|
37
|
+
expect(SDK_INSTALL_TABLE.csharp.kind).toBe('sdk');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('resolveInstallEntry', () => {
|
|
42
|
+
it('returns the full command list when no package manager override is given', () => {
|
|
43
|
+
const entry = resolveInstallEntry('node');
|
|
44
|
+
expect(entry.installCommands).toHaveLength(3);
|
|
45
|
+
expect(entry.installCommands.map((c) => c.manager).sort()).toEqual(['npm', 'pnpm', 'yarn']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('narrows to a single command when the package manager is recognized (case-insensitive)', () => {
|
|
49
|
+
const entry = resolveInstallEntry('node', 'YARN');
|
|
50
|
+
expect(entry.installCommands).toHaveLength(1);
|
|
51
|
+
expect(entry.installCommands[0]?.command).toBe('yarn add @zyphr-dev/node-sdk');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('falls back to the full list when the package manager is not recognized', () => {
|
|
55
|
+
const entry = resolveInstallEntry('node', 'rye');
|
|
56
|
+
expect(entry.installCommands).toHaveLength(3);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not mutate the source table when narrowing', () => {
|
|
60
|
+
resolveInstallEntry('python', 'poetry');
|
|
61
|
+
expect(SDK_INSTALL_TABLE.python.installCommands.length).toBeGreaterThan(1);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// TODO(v0.3): auto-generate these snippets from packages/sdk/<lang>/examples/
|
|
2
|
+
// so docs and MCP output never drift. For v0.2 they are hand-maintained and
|
|
3
|
+
// must mirror apps/docs/docs/sdks/<lang>.md exactly.
|
|
4
|
+
import type { SdkLanguage } from '../schemas.js';
|
|
5
|
+
|
|
6
|
+
export interface InstallCommand {
|
|
7
|
+
manager: string;
|
|
8
|
+
command: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface InitSnippet {
|
|
12
|
+
imports: string;
|
|
13
|
+
init: string;
|
|
14
|
+
fileExample: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SdkKind = 'sdk' | 'rest-client';
|
|
18
|
+
|
|
19
|
+
export interface SdkInstallEntry {
|
|
20
|
+
language: SdkLanguage;
|
|
21
|
+
kind: SdkKind;
|
|
22
|
+
packageName: string;
|
|
23
|
+
registry: string;
|
|
24
|
+
registryUrl: string;
|
|
25
|
+
installCommands: InstallCommand[];
|
|
26
|
+
initSnippet: InitSnippet;
|
|
27
|
+
envVarsNeeded: string[];
|
|
28
|
+
docsUrl: string;
|
|
29
|
+
notes?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DOCS = 'https://docs.zyphr.dev/sdks';
|
|
33
|
+
|
|
34
|
+
export const SDK_INSTALL_TABLE: Record<SdkLanguage, SdkInstallEntry> = {
|
|
35
|
+
node: {
|
|
36
|
+
language: 'node',
|
|
37
|
+
kind: 'sdk',
|
|
38
|
+
packageName: '@zyphr-dev/node-sdk',
|
|
39
|
+
registry: 'npm',
|
|
40
|
+
registryUrl: 'https://www.npmjs.com/package/@zyphr-dev/node-sdk',
|
|
41
|
+
installCommands: [
|
|
42
|
+
{ manager: 'npm', command: 'npm install @zyphr-dev/node-sdk' },
|
|
43
|
+
{ manager: 'yarn', command: 'yarn add @zyphr-dev/node-sdk' },
|
|
44
|
+
{ manager: 'pnpm', command: 'pnpm add @zyphr-dev/node-sdk' },
|
|
45
|
+
],
|
|
46
|
+
initSnippet: {
|
|
47
|
+
imports: "import { Zyphr } from '@zyphr-dev/node-sdk';",
|
|
48
|
+
init: "export const zyphr = new Zyphr({ apiKey: process.env.ZYPHR_API_KEY! });",
|
|
49
|
+
fileExample: 'src/lib/zyphr.ts',
|
|
50
|
+
},
|
|
51
|
+
envVarsNeeded: ['ZYPHR_API_KEY'],
|
|
52
|
+
docsUrl: `${DOCS}/node`,
|
|
53
|
+
},
|
|
54
|
+
csharp: {
|
|
55
|
+
language: 'csharp',
|
|
56
|
+
kind: 'sdk',
|
|
57
|
+
packageName: 'ZyphrDev.SDK',
|
|
58
|
+
registry: 'NuGet',
|
|
59
|
+
registryUrl: 'https://www.nuget.org/packages/ZyphrDev.SDK',
|
|
60
|
+
installCommands: [
|
|
61
|
+
{ manager: 'dotnet', command: 'dotnet add package ZyphrDev.SDK' },
|
|
62
|
+
{ manager: 'nuget', command: 'Install-Package ZyphrDev.SDK' },
|
|
63
|
+
],
|
|
64
|
+
initSnippet: {
|
|
65
|
+
imports: [
|
|
66
|
+
'using ZyphrDev.SDK.Api;',
|
|
67
|
+
'using ZyphrDev.SDK.Client;',
|
|
68
|
+
'using ZyphrDev.SDK.Model;',
|
|
69
|
+
].join('\n'),
|
|
70
|
+
init: [
|
|
71
|
+
'var config = new Configuration',
|
|
72
|
+
'{',
|
|
73
|
+
' ApiKey = new Dictionary<string, string>',
|
|
74
|
+
' {',
|
|
75
|
+
' { "X-API-Key", Environment.GetEnvironmentVariable("ZYPHR_API_KEY")! }',
|
|
76
|
+
' }',
|
|
77
|
+
'};',
|
|
78
|
+
'var emails = new EmailsApi(config);',
|
|
79
|
+
].join('\n'),
|
|
80
|
+
fileExample: 'Services/ZyphrClient.cs',
|
|
81
|
+
},
|
|
82
|
+
envVarsNeeded: ['ZYPHR_API_KEY'],
|
|
83
|
+
docsUrl: `${DOCS}/csharp`,
|
|
84
|
+
},
|
|
85
|
+
ruby: {
|
|
86
|
+
language: 'ruby',
|
|
87
|
+
kind: 'sdk',
|
|
88
|
+
packageName: 'zyphr',
|
|
89
|
+
registry: 'RubyGems',
|
|
90
|
+
registryUrl: 'https://rubygems.org/gems/zyphr',
|
|
91
|
+
installCommands: [
|
|
92
|
+
{ manager: 'gem', command: 'gem install zyphr' },
|
|
93
|
+
{ manager: 'bundler', command: "bundle add zyphr" },
|
|
94
|
+
],
|
|
95
|
+
initSnippet: {
|
|
96
|
+
imports: "require 'zyphr'",
|
|
97
|
+
init: [
|
|
98
|
+
'Zyphr.configure do |config|',
|
|
99
|
+
" config.api_key['X-API-Key'] = ENV.fetch('ZYPHR_API_KEY')",
|
|
100
|
+
'end',
|
|
101
|
+
].join('\n'),
|
|
102
|
+
fileExample: 'config/initializers/zyphr.rb',
|
|
103
|
+
},
|
|
104
|
+
envVarsNeeded: ['ZYPHR_API_KEY'],
|
|
105
|
+
docsUrl: `${DOCS}/ruby`,
|
|
106
|
+
},
|
|
107
|
+
python: {
|
|
108
|
+
language: 'python',
|
|
109
|
+
kind: 'rest-client',
|
|
110
|
+
packageName: 'requests',
|
|
111
|
+
registry: 'PyPI',
|
|
112
|
+
registryUrl: 'https://pypi.org/project/requests/',
|
|
113
|
+
installCommands: [
|
|
114
|
+
{ manager: 'pip', command: 'pip install requests' },
|
|
115
|
+
{ manager: 'poetry', command: 'poetry add requests' },
|
|
116
|
+
{ manager: 'uv', command: 'uv add requests' },
|
|
117
|
+
],
|
|
118
|
+
initSnippet: {
|
|
119
|
+
imports: 'import os\nimport requests',
|
|
120
|
+
init: [
|
|
121
|
+
'ZYPHR_API_KEY = os.environ["ZYPHR_API_KEY"]',
|
|
122
|
+
'BASE_URL = "https://api.zyphr.dev/v1"',
|
|
123
|
+
'',
|
|
124
|
+
'headers = {',
|
|
125
|
+
' "X-API-Key": ZYPHR_API_KEY,',
|
|
126
|
+
' "Content-Type": "application/json",',
|
|
127
|
+
'}',
|
|
128
|
+
'',
|
|
129
|
+
'def zyphr_request(method, path, json=None, params=None):',
|
|
130
|
+
' response = requests.request(',
|
|
131
|
+
' method, f"{BASE_URL}{path}", headers=headers, json=json, params=params,',
|
|
132
|
+
' )',
|
|
133
|
+
' response.raise_for_status()',
|
|
134
|
+
' return response.json()',
|
|
135
|
+
].join('\n'),
|
|
136
|
+
fileExample: 'app/zyphr_client.py',
|
|
137
|
+
},
|
|
138
|
+
envVarsNeeded: ['ZYPHR_API_KEY'],
|
|
139
|
+
docsUrl: `${DOCS}/python`,
|
|
140
|
+
notes:
|
|
141
|
+
'There is no official Zyphr Python SDK yet — the canonical integration is a thin REST wrapper around the requests library.',
|
|
142
|
+
},
|
|
143
|
+
go: {
|
|
144
|
+
language: 'go',
|
|
145
|
+
kind: 'rest-client',
|
|
146
|
+
packageName: 'net/http (stdlib)',
|
|
147
|
+
registry: 'stdlib',
|
|
148
|
+
registryUrl: 'https://pkg.go.dev/net/http',
|
|
149
|
+
installCommands: [
|
|
150
|
+
{ manager: 'go', command: '# No install needed — net/http ships with Go.' },
|
|
151
|
+
],
|
|
152
|
+
initSnippet: {
|
|
153
|
+
imports: [
|
|
154
|
+
'package zyphr',
|
|
155
|
+
'',
|
|
156
|
+
'import (',
|
|
157
|
+
'\t"bytes"',
|
|
158
|
+
'\t"encoding/json"',
|
|
159
|
+
'\t"fmt"',
|
|
160
|
+
'\t"io"',
|
|
161
|
+
'\t"net/http"',
|
|
162
|
+
'\t"os"',
|
|
163
|
+
')',
|
|
164
|
+
].join('\n'),
|
|
165
|
+
init: [
|
|
166
|
+
'const baseURL = "https://api.zyphr.dev/v1"',
|
|
167
|
+
'',
|
|
168
|
+
'type Client struct {',
|
|
169
|
+
'\tAPIKey string',
|
|
170
|
+
'\tHTTPClient *http.Client',
|
|
171
|
+
'}',
|
|
172
|
+
'',
|
|
173
|
+
'func NewClient() *Client {',
|
|
174
|
+
'\treturn &Client{APIKey: os.Getenv("ZYPHR_API_KEY"), HTTPClient: &http.Client{}}',
|
|
175
|
+
'}',
|
|
176
|
+
'',
|
|
177
|
+
'func (c *Client) Do(method, path string, body any) ([]byte, error) {',
|
|
178
|
+
'\tvar buf io.Reader',
|
|
179
|
+
'\tif body != nil {',
|
|
180
|
+
'\t\tb, err := json.Marshal(body)',
|
|
181
|
+
'\t\tif err != nil { return nil, fmt.Errorf("marshal: %w", err) }',
|
|
182
|
+
'\t\tbuf = bytes.NewReader(b)',
|
|
183
|
+
'\t}',
|
|
184
|
+
'\treq, err := http.NewRequest(method, baseURL+path, buf)',
|
|
185
|
+
'\tif err != nil { return nil, err }',
|
|
186
|
+
'\treq.Header.Set("X-API-Key", c.APIKey)',
|
|
187
|
+
'\treq.Header.Set("Content-Type", "application/json")',
|
|
188
|
+
'\tresp, err := c.HTTPClient.Do(req)',
|
|
189
|
+
'\tif err != nil { return nil, err }',
|
|
190
|
+
'\tdefer resp.Body.Close()',
|
|
191
|
+
'\tdata, _ := io.ReadAll(resp.Body)',
|
|
192
|
+
'\tif resp.StatusCode >= 400 { return nil, fmt.Errorf("zyphr %d: %s", resp.StatusCode, data) }',
|
|
193
|
+
'\treturn data, nil',
|
|
194
|
+
'}',
|
|
195
|
+
].join('\n'),
|
|
196
|
+
fileExample: 'internal/zyphr/client.go',
|
|
197
|
+
},
|
|
198
|
+
envVarsNeeded: ['ZYPHR_API_KEY'],
|
|
199
|
+
docsUrl: `${DOCS}/go`,
|
|
200
|
+
notes:
|
|
201
|
+
'There is no official Zyphr Go SDK yet — the canonical integration is a thin REST wrapper around net/http.',
|
|
202
|
+
},
|
|
203
|
+
php: {
|
|
204
|
+
language: 'php',
|
|
205
|
+
kind: 'rest-client',
|
|
206
|
+
packageName: 'guzzlehttp/guzzle',
|
|
207
|
+
registry: 'Packagist',
|
|
208
|
+
registryUrl: 'https://packagist.org/packages/guzzlehttp/guzzle',
|
|
209
|
+
installCommands: [
|
|
210
|
+
{ manager: 'composer', command: 'composer require guzzlehttp/guzzle' },
|
|
211
|
+
],
|
|
212
|
+
initSnippet: {
|
|
213
|
+
imports: [
|
|
214
|
+
'<?php',
|
|
215
|
+
'',
|
|
216
|
+
'use GuzzleHttp\\Client;',
|
|
217
|
+
].join('\n'),
|
|
218
|
+
init: [
|
|
219
|
+
'$client = new Client([',
|
|
220
|
+
" 'base_uri' => 'https://api.zyphr.dev/v1/',",
|
|
221
|
+
" 'headers' => [",
|
|
222
|
+
" 'X-API-Key' => getenv('ZYPHR_API_KEY'),",
|
|
223
|
+
" 'Content-Type' => 'application/json',",
|
|
224
|
+
' ],',
|
|
225
|
+
']);',
|
|
226
|
+
].join('\n'),
|
|
227
|
+
fileExample: 'app/Services/ZyphrClient.php',
|
|
228
|
+
},
|
|
229
|
+
envVarsNeeded: ['ZYPHR_API_KEY'],
|
|
230
|
+
docsUrl: `${DOCS}/php`,
|
|
231
|
+
notes:
|
|
232
|
+
'There is no official Zyphr PHP SDK yet — the canonical integration uses Guzzle (or raw cURL).',
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export function resolveInstallEntry(
|
|
237
|
+
language: SdkLanguage,
|
|
238
|
+
packageManager?: string,
|
|
239
|
+
): SdkInstallEntry {
|
|
240
|
+
const entry = SDK_INSTALL_TABLE[language];
|
|
241
|
+
if (!packageManager) return entry;
|
|
242
|
+
|
|
243
|
+
const normalized = packageManager.trim().toLowerCase();
|
|
244
|
+
const match = entry.installCommands.find((c) => c.manager.toLowerCase() === normalized);
|
|
245
|
+
if (!match) return entry;
|
|
246
|
+
|
|
247
|
+
return { ...entry, installCommands: [match] };
|
|
248
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
ZyphrError,
|
|
4
|
+
ZyphrNotFoundError,
|
|
5
|
+
ZyphrRateLimitError,
|
|
6
|
+
ZyphrValidationError,
|
|
7
|
+
} from '@zyphr-dev/node-sdk';
|
|
8
|
+
import { runTool, toolResult } from './result.js';
|
|
9
|
+
|
|
10
|
+
function parseTextPayload(result: { content: Array<{ type: string; text: string }> }): unknown {
|
|
11
|
+
const block = result.content[0];
|
|
12
|
+
if (!block || block.type !== 'text') throw new Error('expected text content');
|
|
13
|
+
return JSON.parse(block.text);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('toolResult', () => {
|
|
17
|
+
it('serializes success payload as JSON text content', () => {
|
|
18
|
+
const r = toolResult({ ok: true, n: 1 });
|
|
19
|
+
expect(r.content).toEqual([{ type: 'text', text: JSON.stringify({ ok: true, n: 1 }, null, 2) }]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('runTool error rendering', () => {
|
|
24
|
+
it('passes through resolved values', async () => {
|
|
25
|
+
const r = await runTool(async () => ({ hello: 'world' }));
|
|
26
|
+
expect(r.isError).toBeUndefined();
|
|
27
|
+
expect(parseTextPayload(r)).toEqual({ hello: 'world' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('surfaces ZyphrNotFoundError with status + code', async () => {
|
|
31
|
+
const r = await runTool(async () => {
|
|
32
|
+
throw new ZyphrNotFoundError('The requested resource was not found');
|
|
33
|
+
});
|
|
34
|
+
expect(r.isError).toBe(true);
|
|
35
|
+
expect(parseTextPayload(r)).toEqual({
|
|
36
|
+
name: 'ZyphrNotFoundError',
|
|
37
|
+
message: 'The requested resource was not found',
|
|
38
|
+
status: 404,
|
|
39
|
+
code: 'not_found',
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('includes details + requestId on a base ZyphrError', async () => {
|
|
44
|
+
const r = await runTool(async () => {
|
|
45
|
+
throw new ZyphrError({
|
|
46
|
+
message: 'Internal explosion',
|
|
47
|
+
status: 500,
|
|
48
|
+
code: 'server_error',
|
|
49
|
+
requestId: 'req_abc123',
|
|
50
|
+
details: { hint: 'try again' },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
expect(r.isError).toBe(true);
|
|
54
|
+
expect(parseTextPayload(r)).toEqual({
|
|
55
|
+
name: 'ZyphrError',
|
|
56
|
+
message: 'Internal explosion',
|
|
57
|
+
status: 500,
|
|
58
|
+
code: 'server_error',
|
|
59
|
+
requestId: 'req_abc123',
|
|
60
|
+
details: { hint: 'try again' },
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('surfaces validation details', async () => {
|
|
65
|
+
const r = await runTool(async () => {
|
|
66
|
+
throw new ZyphrValidationError('Missing field', { field: 'subject' });
|
|
67
|
+
});
|
|
68
|
+
expect(parseTextPayload(r)).toMatchObject({
|
|
69
|
+
name: 'ZyphrValidationError',
|
|
70
|
+
status: 422,
|
|
71
|
+
code: 'validation_error',
|
|
72
|
+
details: { field: 'subject' },
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('includes retryAfter for rate-limit errors', async () => {
|
|
77
|
+
const r = await runTool(async () => {
|
|
78
|
+
throw new ZyphrRateLimitError('Slow down', 30);
|
|
79
|
+
});
|
|
80
|
+
expect(parseTextPayload(r)).toMatchObject({
|
|
81
|
+
name: 'ZyphrRateLimitError',
|
|
82
|
+
status: 429,
|
|
83
|
+
code: 'rate_limit_exceeded',
|
|
84
|
+
retryAfter: 30,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('falls back to {name, message} for plain Error', async () => {
|
|
89
|
+
const r = await runTool(async () => {
|
|
90
|
+
throw new Error('boom');
|
|
91
|
+
});
|
|
92
|
+
expect(parseTextPayload(r)).toEqual({ name: 'Error', message: 'boom' });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('renders raw Response error with status + parsed body', async () => {
|
|
96
|
+
const body = { error: { message: 'Bad request', code: 'invalid_request' } };
|
|
97
|
+
const response = new Response(JSON.stringify(body), {
|
|
98
|
+
status: 400,
|
|
99
|
+
headers: { 'content-type': 'application/json' },
|
|
100
|
+
});
|
|
101
|
+
const r = await runTool(async () => {
|
|
102
|
+
// Mimic the pre-middleware shape (rare in practice).
|
|
103
|
+
throw Object.assign(new Error('Bad request'), { response });
|
|
104
|
+
});
|
|
105
|
+
expect(parseTextPayload(r)).toEqual({ status: 400, body });
|
|
106
|
+
});
|
|
107
|
+
});
|
package/src/result.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { ZyphrError, ZyphrRateLimitError } from '@zyphr-dev/node-sdk';
|
|
3
|
+
|
|
4
|
+
export function toolResult(data: unknown): CallToolResult {
|
|
5
|
+
return {
|
|
6
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function runTool(fn: () => Promise<unknown>): Promise<CallToolResult> {
|
|
11
|
+
try {
|
|
12
|
+
const data = await fn();
|
|
13
|
+
return toolResult(data);
|
|
14
|
+
} catch (err: unknown) {
|
|
15
|
+
return await renderApiError(err);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function errorPayload(content: Record<string, unknown>): CallToolResult {
|
|
20
|
+
return {
|
|
21
|
+
isError: true,
|
|
22
|
+
content: [{ type: 'text', text: JSON.stringify(content, null, 2) }],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function renderApiError(err: unknown): Promise<CallToolResult> {
|
|
27
|
+
// Typed SDK errors — surface the full envelope so the AI can act on it.
|
|
28
|
+
if (err instanceof ZyphrError) {
|
|
29
|
+
const payload: Record<string, unknown> = {
|
|
30
|
+
name: err.name,
|
|
31
|
+
message: err.message,
|
|
32
|
+
status: err.status,
|
|
33
|
+
};
|
|
34
|
+
if (err.code) payload.code = err.code;
|
|
35
|
+
if (err.requestId) payload.requestId = err.requestId;
|
|
36
|
+
if (err.details) payload.details = err.details;
|
|
37
|
+
if (err instanceof ZyphrRateLimitError && err.retryAfter !== undefined) {
|
|
38
|
+
payload.retryAfter = err.retryAfter;
|
|
39
|
+
}
|
|
40
|
+
return errorPayload(payload);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Raw Response error (rare — SDK middleware usually parses first).
|
|
44
|
+
if (err && typeof err === 'object' && 'response' in err) {
|
|
45
|
+
const response = (err as { response?: Response }).response;
|
|
46
|
+
if (response && typeof response.text === 'function') {
|
|
47
|
+
try {
|
|
48
|
+
const text = await response.text();
|
|
49
|
+
let parsed: unknown = text;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(text);
|
|
52
|
+
} catch {
|
|
53
|
+
/* keep raw text */
|
|
54
|
+
}
|
|
55
|
+
return errorPayload({ status: response.status, body: parsed });
|
|
56
|
+
} catch {
|
|
57
|
+
/* fall through to generic message */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
63
|
+
const name = err instanceof Error ? err.name : 'Error';
|
|
64
|
+
return errorPayload({ name, message });
|
|
65
|
+
}
|