@pyai/mcp 0.2.0 → 0.3.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/dist/hosted.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createMcpServer, MCP_VERSION } from "./server.js";
2
+ export { PyAIClient } from "./client.js";
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 { dispatch } from "./rpc.js";
20
- const MAX_BODY_BYTES = 1_000_000;
6
+ import { localContext } from "./tools.js";
21
7
  export function startHttpServer(opts) {
22
- const server = createServer((req, res) => {
23
- if (req.method === "GET" && (req.url === "/health" || req.url === "/healthz")) {
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(JSON.stringify({ status: "ok", server: "pyai-mcp" }));
14
+ res.end('{"status":"ok"}');
26
15
  return;
27
16
  }
28
- if (req.method !== "POST") {
29
- res.writeHead(405, { "content-type": "application/json", allow: "POST" });
30
- res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Use POST for JSON-RPC" } }));
17
+ if (req.url !== "/mcp") {
18
+ res.writeHead(404);
19
+ res.end();
31
20
  return;
32
21
  }
33
- const auth = req.headers["authorization"];
34
- const bearer = typeof auth === "string" && auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
35
- const client = new PyAIClient({ apiKey: bearer || opts.apiKey, baseUrl: opts.baseUrl });
36
- let raw = "";
37
- let aborted = false;
38
- req.on("data", (chunk) => {
39
- raw += chunk;
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
- req.on("end", () => {
48
- if (aborted)
49
- return;
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, opts.host ?? "0.0.0.0", () => {
72
- process.stderr.write(`pyai-mcp: HTTP transport listening on ${opts.host ?? "0.0.0.0"}:${opts.port}\n`);
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
- * pyai-mcp — Model Context Protocol server for the PyAI API.
4
- *
5
- * Exposes PyAI operations (sandbox key minting, models, voices, TTS, async
6
- * transcription) as MCP tools so AI coding agents (Cursor, Claude Code, Codex)
7
- * can call them directly. Default transport is stdio: reads newline-delimited
8
- * JSON-RPC from stdin, writes responses to stdout. All diagnostics go to stderr
9
- * so they never corrupt the protocol. Pass `--http` for the experimental HTTP
10
- * transport (for hosting a remote server).
11
- *
12
- * Config (env):
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 { dispatch } from "./rpc.js";
14
+ import { createMcpServer, MCP_VERSION } from "./server.js";
22
15
  import { startHttpServer } from "./http.js";
23
- function startStdio(client) {
24
- const rl = createInterface({ input: process.stdin });
25
- const write = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
26
- rl.on("line", (line) => {
27
- const trimmed = line.trim();
28
- if (!trimmed)
29
- return;
30
- let msg;
31
- try {
32
- msg = JSON.parse(trimmed);
33
- }
34
- catch {
35
- write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
36
- return;
37
- }
38
- // Process sequentially; each line is an independent JSON-RPC message.
39
- dispatch(msg, { client })
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
- rl.on("close", () => process.exit(0));
52
- process.stderr.write("pyai-mcp: ready on stdio\n");
53
- }
54
- function main() {
55
- const apiKey = process.env.PYAI_API_KEY;
56
- const baseUrl = process.env.PYAI_BASE_URL;
57
- if (!apiKey) {
58
- process.stderr.write("pyai-mcp: no PYAI_API_KEY set — call the create_sandbox_key tool to mint a free key.\n");
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
- if (process.argv.includes("--http")) {
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
- startHttpServer({ apiKey, baseUrl, port });
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
- startStdio(new PyAIClient({ apiKey, baseUrl }));
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 = "2024-11-05";
14
- export const SERVER_INFO = { name: "pyai-mcp", version: "0.2.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 — no response
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.0";
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
+ }