@pyai/mcp 0.2.0 → 0.3.1
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/AGENT_GUIDE.md +47 -0
- package/README.md +81 -65
- package/dist/client.js +94 -52
- package/dist/extra-tools.js +150 -0
- package/dist/generated-tools.js +1473 -0
- package/dist/hosted.js +2 -0
- package/dist/http.js +35 -62
- package/dist/index.js +61 -58
- package/dist/rpc.js +3 -3
- package/dist/server.js +93 -0
- package/dist/tools.js +527 -82
- package/dist/workflows.js +127 -0
- package/package.json +23 -8
- package/server.json +30 -0
package/dist/hosted.js
ADDED
package/dist/http.js
CHANGED
|
@@ -1,74 +1,47 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Experimental HTTP transport for hosting pyai-mcp remotely (the basis for a
|
|
3
|
-
* one-click remote MCP at mcp.pyai.com).
|
|
4
|
-
*
|
|
5
|
-
* This is a minimal single-response JSON-RPC endpoint: POST a JSON-RPC message,
|
|
6
|
-
* get the JSON-RPC response back. It is intentionally small and dependency-free.
|
|
7
|
-
* NOT YET a full MCP Streamable HTTP server — SSE streaming, session ids
|
|
8
|
-
* (`Mcp-Session-Id`), and OAuth are deploy-time follow-ups before public
|
|
9
|
-
* hosting. The supported install today is `npx @pyai/mcp` over stdio.
|
|
10
|
-
*
|
|
11
|
-
* A per-request `Authorization: Bearer <key>` overrides the env key, so a hosted
|
|
12
|
-
* instance can serve multiple tenants. Each request gets its own client instance:
|
|
13
|
-
* if `create_sandbox_key` adopts a key, that mutation is confined to the single
|
|
14
|
-
* request and cannot leak into the next caller. Full remote MCP session support
|
|
15
|
-
* still needs Streamable HTTP session ids before public hosting.
|
|
16
|
-
*/
|
|
1
|
+
/** Loopback development host. Production OAuth is hosted by the control plane. */
|
|
17
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
import { createMcpServer } from "./server.js";
|
|
18
5
|
import { PyAIClient } from "./client.js";
|
|
19
|
-
import {
|
|
20
|
-
const MAX_BODY_BYTES = 1_000_000;
|
|
6
|
+
import { localContext } from "./tools.js";
|
|
21
7
|
export function startHttpServer(opts) {
|
|
22
|
-
const
|
|
23
|
-
|
|
8
|
+
const host = opts.host ?? "127.0.0.1";
|
|
9
|
+
if (!["127.0.0.1", "::1", "localhost"].includes(host))
|
|
10
|
+
throw Error("Development HTTP binds to loopback only. Use the hosted OAuth endpoint for remote clients.");
|
|
11
|
+
const server = createServer(async (req, res) => {
|
|
12
|
+
if (req.url === "/healthz" && req.method === "GET") {
|
|
24
13
|
res.writeHead(200, { "content-type": "application/json" });
|
|
25
|
-
res.end(
|
|
14
|
+
res.end('{"status":"ok"}');
|
|
26
15
|
return;
|
|
27
16
|
}
|
|
28
|
-
if (req.
|
|
29
|
-
res.writeHead(
|
|
30
|
-
res.end(
|
|
17
|
+
if (req.url !== "/mcp") {
|
|
18
|
+
res.writeHead(404);
|
|
19
|
+
res.end();
|
|
31
20
|
return;
|
|
32
21
|
}
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (raw.length > MAX_BODY_BYTES) {
|
|
41
|
-
aborted = true;
|
|
42
|
-
res.writeHead(413, { "content-type": "application/json" });
|
|
43
|
-
res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Payload too large" } }));
|
|
44
|
-
req.destroy();
|
|
45
|
-
}
|
|
22
|
+
const mcp = createMcpServer(new PyAIClient({ apiKey: opts.apiKey, baseUrl: opts.baseUrl }), { ...localContext, mode: "http" });
|
|
23
|
+
const transport = new StreamableHTTPServerTransport({
|
|
24
|
+
sessionIdGenerator: undefined,
|
|
25
|
+
enableJsonResponse: true,
|
|
26
|
+
enableDnsRebindingProtection: true,
|
|
27
|
+
allowedHosts: ["127.0.0.1", "localhost", "[::1]"].map((h) => `${h}:${server.address().port}`),
|
|
28
|
+
allowedOrigins: ["127.0.0.1", "localhost", "[::1]"].map((h) => `http://${h}:${server.address().port}`),
|
|
46
29
|
});
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
let msg;
|
|
51
|
-
try {
|
|
52
|
-
msg = JSON.parse(raw);
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
56
|
-
res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }));
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
dispatch(msg, { client })
|
|
60
|
-
.then((out) => {
|
|
61
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
62
|
-
// Notifications (no id) get an empty 200 ack.
|
|
63
|
-
res.end(out ? JSON.stringify(out) : "");
|
|
64
|
-
})
|
|
65
|
-
.catch(() => {
|
|
66
|
-
res.writeHead(500, { "content-type": "application/json" });
|
|
67
|
-
res.end(JSON.stringify({ jsonrpc: "2.0", id: msg.id ?? null, error: { code: -32603, message: "Internal error" } }));
|
|
68
|
-
});
|
|
30
|
+
res.on("close", () => {
|
|
31
|
+
void transport.close();
|
|
32
|
+
void mcp.close();
|
|
69
33
|
});
|
|
34
|
+
try {
|
|
35
|
+
await mcp.connect(transport);
|
|
36
|
+
await transport.handleRequest(req, res);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
if (!res.headersSent) {
|
|
40
|
+
res.writeHead(500);
|
|
41
|
+
res.end('{"error":"server_error"}');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
70
44
|
});
|
|
71
|
-
server.listen(opts.port,
|
|
72
|
-
|
|
73
|
-
});
|
|
45
|
+
server.listen(opts.port, host, () => process.stderr.write(`PyAI MCP development endpoint: http://${host}:${server.address().port}/mcp\n`));
|
|
46
|
+
return server;
|
|
74
47
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,67 +1,70 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* PYAI_API_KEY (OPTIONAL) — a pyai_live_ or pyai_test_ key. If absent, the
|
|
14
|
-
* server still starts; call the `create_sandbox_key` tool to
|
|
15
|
-
* mint one with no human steps.
|
|
16
|
-
* PYAI_BASE_URL (optional) — defaults to https://api.pyai.com.
|
|
17
|
-
* PORT (optional, --http only) — defaults to 8080.
|
|
18
|
-
*/
|
|
19
|
-
import { createInterface } from "node:readline";
|
|
2
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
3
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
4
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
5
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
return path;
|
|
9
|
+
};
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
20
13
|
import { PyAIClient } from "./client.js";
|
|
21
|
-
import {
|
|
14
|
+
import { createMcpServer, MCP_VERSION } from "./server.js";
|
|
22
15
|
import { startHttpServer } from "./http.js";
|
|
23
|
-
function
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.then((res) => {
|
|
41
|
-
if (res)
|
|
42
|
-
write(res);
|
|
43
|
-
})
|
|
44
|
-
.catch((err) => {
|
|
45
|
-
process.stderr.write(`pyai-mcp: dispatch error: ${err.message}\n`);
|
|
46
|
-
if (msg.id !== undefined && msg.id !== null) {
|
|
47
|
-
write({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: "Internal error" } });
|
|
48
|
-
}
|
|
16
|
+
async function main() {
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
if (args.includes("--version")) {
|
|
19
|
+
process.stdout.write(MCP_VERSION + "\n");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
23
|
+
process.stdout.write(`PyAI MCP ${MCP_VERSION}\n\nRemote endpoint: https://api.pyai.com/mcp\n Add this URL in ChatGPT, Claude, Cursor or Codex and approve browser login.\n\nLocal use:\n pyai-mcp login [--no-browser] [--profile NAME] Sign in using the PyAI CLI\n pyai-mcp [--profile NAME] Serve MCP over stdio\n pyai-mcp --http Loopback development server\n\nPYAI_API_KEY overrides the selected local profile. No credentials are printed.\n`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// The dependency is pinned: these tested internal modules share exactly the CLI's
|
|
27
|
+
// permission checks, profile format and browser device flow without a second store.
|
|
28
|
+
const sdkEntry = import.meta.resolve("@pyai/sdk");
|
|
29
|
+
if (args[0] === "login") {
|
|
30
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL("./cli.js", sdkEntry)), "login", ...args.slice(1)], { stdio: "inherit", env: process.env });
|
|
31
|
+
child.on("exit", (code) => {
|
|
32
|
+
process.exitCode = code ?? 1;
|
|
49
33
|
});
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
34
|
+
child.on("error", () => {
|
|
35
|
+
process.stderr.write("Could not start PyAI login.\n");
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
let profile;
|
|
41
|
+
for (let i = 0; i < args.length; i++) {
|
|
42
|
+
if (args[i] === "--http")
|
|
43
|
+
continue;
|
|
44
|
+
if (args[i] === "--profile" && args[i + 1]) {
|
|
45
|
+
profile = args[++i];
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
throw Error(`Unknown option: ${args[i]}. Run pyai-mcp --help.`);
|
|
59
49
|
}
|
|
60
|
-
|
|
50
|
+
const { resolveConfig } = (await import(__rewriteRelativeImportExtension(new URL("./cli-config.js", sdkEntry).href)));
|
|
51
|
+
const config = await resolveConfig({ profile });
|
|
52
|
+
if (args.includes("--http")) {
|
|
61
53
|
const port = Number(process.env.PORT ?? 8080);
|
|
62
|
-
|
|
54
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
55
|
+
throw Error("Invalid PORT.");
|
|
56
|
+
startHttpServer({ apiKey: config.apiKey, baseUrl: config.baseURL, port });
|
|
63
57
|
return;
|
|
64
58
|
}
|
|
65
|
-
|
|
59
|
+
const server = createMcpServer(new PyAIClient({ apiKey: config.apiKey, baseUrl: config.baseURL }));
|
|
60
|
+
await server.connect(new StdioServerTransport());
|
|
61
|
+
process.stderr.write("PyAI MCP ready. " +
|
|
62
|
+
(config.apiKey
|
|
63
|
+
? "Using your authorized PyAI profile."
|
|
64
|
+
: "Run pyai-mcp login to connect your account.") +
|
|
65
|
+
"\n");
|
|
66
66
|
}
|
|
67
|
-
main()
|
|
67
|
+
main().catch(() => {
|
|
68
|
+
process.stderr.write("PyAI MCP could not start. Check your options and profile with pyai-mcp --help or run pyai-mcp login.\n");
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
});
|
package/dist/rpc.js
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* response or null for notifications) so it unit-tests without spawning a process.
|
|
11
11
|
*/
|
|
12
12
|
import { callTool, TOOLS } from "./tools.js";
|
|
13
|
-
export const PROTOCOL_VERSION = "
|
|
14
|
-
export const SERVER_INFO = { name: "pyai-mcp", version: "0.
|
|
13
|
+
export const PROTOCOL_VERSION = "2025-11-25";
|
|
14
|
+
export const SERVER_INFO = { name: "pyai-mcp", version: "0.3.0" };
|
|
15
15
|
function ok(id, result) {
|
|
16
16
|
return { jsonrpc: "2.0", id: id ?? null, result };
|
|
17
17
|
}
|
|
@@ -31,7 +31,7 @@ export async function dispatch(msg, deps) {
|
|
|
31
31
|
serverInfo: SERVER_INFO,
|
|
32
32
|
});
|
|
33
33
|
case "notifications/initialized":
|
|
34
|
-
return null; // notification
|
|
34
|
+
return null; // notification, no response
|
|
35
35
|
case "ping":
|
|
36
36
|
return ok(msg.id, {});
|
|
37
37
|
case "tools/list":
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, McpError, ErrorCode, } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { TOOLS, TOOL_SCOPES, callTool, localContext, } from "./tools.js";
|
|
4
|
+
import { GUIDE, RESOURCES, SERVER_INSTRUCTIONS, WORKFLOWS, } from "./workflows.js";
|
|
5
|
+
export const MCP_VERSION = "0.3.1";
|
|
6
|
+
export function createMcpServer(client, context = localContext) {
|
|
7
|
+
const server = new Server({
|
|
8
|
+
name: "pyai",
|
|
9
|
+
version: MCP_VERSION,
|
|
10
|
+
title: "PyAI Voice AI",
|
|
11
|
+
websiteUrl: "https://pyai.com/mcp",
|
|
12
|
+
}, {
|
|
13
|
+
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
14
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
15
|
+
});
|
|
16
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
17
|
+
tools: TOOLS.filter((t) => context.mode === "stdio" || t.name !== "create_sandbox_key").map((t) => context.mode === "stdio"
|
|
18
|
+
? t
|
|
19
|
+
: {
|
|
20
|
+
...t,
|
|
21
|
+
securitySchemes: [
|
|
22
|
+
{
|
|
23
|
+
type: "oauth2",
|
|
24
|
+
scopes: TOOL_SCOPES[t.name] ? [TOOL_SCOPES[t.name]] : [],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
_meta: {
|
|
28
|
+
...t._meta,
|
|
29
|
+
securitySchemes: [
|
|
30
|
+
{
|
|
31
|
+
type: "oauth2",
|
|
32
|
+
scopes: TOOL_SCOPES[t.name] ? [TOOL_SCOPES[t.name]] : [],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
39
|
+
if (!TOOLS.some((t) => t.name === request.params.name) ||
|
|
40
|
+
(context.mode === "http" && request.params.name === "create_sandbox_key"))
|
|
41
|
+
throw new McpError(ErrorCode.InvalidParams, "Unknown tool.");
|
|
42
|
+
return (await callTool(request.params.name, request.params.arguments ?? {}, client, context));
|
|
43
|
+
});
|
|
44
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
45
|
+
resources: RESOURCES,
|
|
46
|
+
}));
|
|
47
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
48
|
+
const resource = RESOURCES.find((r) => r.uri === request.params.uri);
|
|
49
|
+
if (!resource)
|
|
50
|
+
throw new McpError(ErrorCode.InvalidParams, "Unknown resource.");
|
|
51
|
+
return {
|
|
52
|
+
contents: [
|
|
53
|
+
{
|
|
54
|
+
uri: resource.uri,
|
|
55
|
+
mimeType: resource.mimeType,
|
|
56
|
+
text: resource.uri === "pyai://guide" ? GUIDE : JSON.stringify(WORKFLOWS),
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
62
|
+
prompts: Object.entries(WORKFLOWS).map(([name, w]) => ({
|
|
63
|
+
name,
|
|
64
|
+
title: w.title,
|
|
65
|
+
description: w.example,
|
|
66
|
+
arguments: [
|
|
67
|
+
{
|
|
68
|
+
name: "task",
|
|
69
|
+
description: "The user-authorized task, without credentials.",
|
|
70
|
+
required: true,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
})),
|
|
74
|
+
}));
|
|
75
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
76
|
+
const workflow = WORKFLOWS[request.params.name];
|
|
77
|
+
if (!workflow || !request.params.arguments?.task)
|
|
78
|
+
throw new McpError(ErrorCode.InvalidParams, "Choose a workflow and provide task.");
|
|
79
|
+
return {
|
|
80
|
+
description: workflow.title,
|
|
81
|
+
messages: [
|
|
82
|
+
{
|
|
83
|
+
role: "user",
|
|
84
|
+
content: {
|
|
85
|
+
type: "text",
|
|
86
|
+
text: `User task: ${request.params.arguments.task}\n\nAvailable workflow: ${workflow.steps.join(" ")}\nTools: ${workflow.tools.join(", ")}\nUse the authorized project. Do not invent input media or credentials.`,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
return server;
|
|
93
|
+
}
|