@pyai/mcp 0.5.0 → 0.5.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 CHANGED
@@ -84,3 +84,17 @@ limits. Authorized organization, project and key IDs remain in structured
84
84
  output; `whoami({"verbose":true})` also includes them in text. This reduces
85
85
  prompt clutter, not host access to identity metadata. Credential-shaped fields
86
86
  are omitted in both modes. Never paste keys or full identity dumps into reports.
87
+
88
+ ## Hosted connection boundaries
89
+
90
+ The hosted OAuth MCP connection uses existing PyAI projects and owned phone numbers.
91
+ Number purchasing is not exposed through hosted MCP. The REST API and local stdio
92
+ integration retain their separate contracts. Hosted agent tool bindings accept
93
+ saved tool IDs and enabled flags, not credentials or arbitrary connection settings.
94
+ Configure those settings privately in the PyAI console. Retained bindings preserve
95
+ their saved settings, and hosted agent responses omit connection configuration.
96
+
97
+ Supply only audio and text you are authorized to process. Do not submit payment-card
98
+ data, protected health information, government identifiers, passwords, or API keys.
99
+ If an operation exceeds the project's entitlement, report the limitation without
100
+ initiating a purchase or repeatedly resubmitting the request.
package/README.md CHANGED
@@ -138,3 +138,17 @@ limits. Authorized organization, project and key IDs remain in structured
138
138
  output; `whoami({"verbose":true})` also includes them in text. This reduces
139
139
  prompt clutter, not host access to identity metadata. Credential-shaped fields
140
140
  are omitted in both modes. Never paste keys or full identity dumps into reports.
141
+
142
+ ## Hosted connection boundaries
143
+
144
+ The hosted OAuth MCP connection uses existing PyAI projects and owned phone numbers.
145
+ Number purchasing is not exposed through hosted MCP. The REST API and local stdio
146
+ integration retain their separate contracts. Hosted agent tool bindings accept
147
+ saved tool IDs and enabled flags, not credentials or arbitrary connection settings.
148
+ Configure those settings privately in the PyAI console. Retained bindings preserve
149
+ their saved settings, and hosted agent responses omit connection configuration.
150
+
151
+ Supply only audio and text you are authorized to process. Do not submit payment-card
152
+ data, protected health information, government identifiers, passwords, or API keys.
153
+ If an operation exceeds the project's entitlement, report the limitation without
154
+ initiating a purchase or repeatedly resubmitting the request.
@@ -28,7 +28,7 @@ export const EXTRA_TOOLS = [
28
28
  },
29
29
  {
30
30
  name: "create_dub",
31
- description: "Submit an audio URL for translation and dubbing. Consumes usage. Keep job_id and poll get_dub_job; do not submit again after a timeout.",
31
+ description: "Fetch audio from the user-authorized HTTPS source URL and start a translation and dubbing job. Accesses an external source and consumes usage. Keep job_id and poll get_dub_job; do not submit again after a timeout.",
32
32
  inputSchema: obj({
33
33
  source_url: { type: "string", format: "uri", pattern: "^https://" },
34
34
  source_lang: string("Source language from get_dub_capabilities."),
@@ -37,8 +37,8 @@ export const EXTRA_TOOLS = [
37
37
  },
38
38
  {
39
39
  name: "get_dub_job",
40
- title: "Dubbing",
41
- description: "Check a submitted Dub job. Poll pending/processing states without resubmitting.",
40
+ title: "Get dubbing job status",
41
+ description: "Retrieve the status and available results of an existing dubbing job by job_id. This read does not start or repeat a job. Poll pending/processing states without resubmitting.",
42
42
  inputSchema: obj(id, ["job_id"]),
43
43
  },
44
44
  {
@@ -88,7 +88,7 @@ export const EXTRA_TOOLS = [
88
88
  },
89
89
  language: {
90
90
  type: "string",
91
- enum: ["en", "es", "fr", "de", "hi", "it", "pt", "nl"],
91
+ enum: ["en", "es", "fr", "de", "hi", "it", "pt", "nl", "zh", "yue", "ar", "id", "ko", "th", "vi", "ja", "tr", "ms", "sv", "da", "fi", "pl", "cs", "fil", "fa", "el", "hu", "mk", "ro"],
92
92
  },
93
93
  }, ["audio_base64", "filename"]),
94
94
  },
@@ -609,7 +609,7 @@ export const API_TOOLS = [
609
609
  "null"
610
610
  ],
611
611
  "maxLength": 200,
612
- "description": "TTS delivery direction for this managed Agent on instruct-capable voice tiers. Omit or set null for PyAI's natural conversational pace; an inline Omni `configure.voice_instruct` wins for that session."
612
+ "description": "Stored delivery direction for instruct-capable voice tiers. Current Omni routes support neutral delivery only: omit or set null for neutral speech. A saved custom value is rejected when configuring Omni; an inline empty `configure.voice_instruct` resets it to neutral for that session."
613
613
  },
614
614
  "brain_model": {
615
615
  "type": [
@@ -686,9 +686,20 @@ export const API_TOOLS = [
686
686
  "es",
687
687
  "de",
688
688
  "hi",
689
+ "zh",
690
+ "it",
691
+ "pt",
692
+ "ja",
693
+ "ko",
689
694
  null
690
695
  ],
691
- "description": "Requested language for this Agent's Omni sessions. `null` or absent means `en`. Availability is staged: as of 2026-08-13 public serving is `en`, `fr`, `es`, and `hi`; `de` falls back to English. Inspect the session's `configured.language_active` and `language_fallback` fields before assuming the requested language is active. See the Language support reference."
696
+ "x-staged-language-ids": [
697
+ "ht",
698
+ "zh-CN",
699
+ "ar-MSA",
700
+ "ar-Gulf"
701
+ ],
702
+ "description": "Requested language for this Agent's Omni sessions. `null` or absent means `en`. Availability is staged: public serving includes `en`, `fr`, `es`, `hi`, `it`, `pt`, `ja`, and `ko`; `de` and the legacy `zh` alias fall back to English unless explicitly enabled with qualified endpoints. Canonical staged route identifiers are exposed in `x-staged-language-ids` for internal integration only and are rejected by the public control plane until qualified. Inspect the session's `configured.language_active` and `language_fallback` fields before assuming the requested language is active. See the Language support reference."
692
703
  },
693
704
  "metadata": {
694
705
  "type": [
@@ -936,7 +947,7 @@ export const API_TOOLS = [
936
947
  "null"
937
948
  ],
938
949
  "maxLength": 200,
939
- "description": "TTS delivery direction for this managed Agent on instruct-capable voice tiers. Omit or set null for PyAI's natural conversational pace; an inline Omni `configure.voice_instruct` wins for that session."
950
+ "description": "Stored delivery direction for instruct-capable voice tiers. Current Omni routes support neutral delivery only: omit or set null for neutral speech. A saved custom value is rejected when configuring Omni; an inline empty `configure.voice_instruct` resets it to neutral for that session."
940
951
  },
941
952
  "brain_model": {
942
953
  "type": [
@@ -1013,9 +1024,20 @@ export const API_TOOLS = [
1013
1024
  "es",
1014
1025
  "de",
1015
1026
  "hi",
1027
+ "zh",
1028
+ "it",
1029
+ "pt",
1030
+ "ja",
1031
+ "ko",
1016
1032
  null
1017
1033
  ],
1018
- "description": "Requested language for this Agent's Omni sessions. `null` or absent means `en`. Availability is staged: as of 2026-08-13 public serving is `en`, `fr`, `es`, and `hi`; `de` falls back to English. Inspect the session's `configured.language_active` and `language_fallback` fields before assuming the requested language is active. See the Language support reference."
1034
+ "x-staged-language-ids": [
1035
+ "ht",
1036
+ "zh-CN",
1037
+ "ar-MSA",
1038
+ "ar-Gulf"
1039
+ ],
1040
+ "description": "Requested language for this Agent's Omni sessions. `null` or absent means `en`. Availability is staged: public serving includes `en`, `fr`, `es`, `hi`, `it`, `pt`, `ja`, and `ko`; `de` and the legacy `zh` alias fall back to English unless explicitly enabled with qualified endpoints. Canonical staged route identifiers are exposed in `x-staged-language-ids` for internal integration only and are rejected by the public control plane until qualified. Inspect the session's `configured.language_active` and `language_fallback` fields before assuming the requested language is active. See the Language support reference."
1019
1041
  },
1020
1042
  "metadata": {
1021
1043
  "type": [
@@ -0,0 +1,33 @@
1
+ // Remote conversational clients use saved connections, never credential inputs
2
+ // or digital-service checkout. The REST API and local stdio retain their contract.
3
+ export const HOSTED_EXCLUDED_TOOLS = new Set(["create_sandbox_key", "telephony_buy_number"]);
4
+ export const AGENT_WRITE_TOOLS = new Set(["create_agent", "update_agent", "set_agent_tools"]);
5
+ export function hostedTools(tools) {
6
+ return tools.filter(t => !HOSTED_EXCLUDED_TOOLS.has(t.name)).map(t => {
7
+ if (!AGENT_WRITE_TOOLS.has(t.name))
8
+ return t;
9
+ const schema = structuredClone(t.inputSchema);
10
+ const body = schema.properties.body;
11
+ body.additionalProperties = false;
12
+ const bindings = t.name === "set_agent_tools" ? body : body.properties.tools;
13
+ if (bindings?.items) {
14
+ bindings.items = {
15
+ type: "object", required: ["tool_id"], additionalProperties: false,
16
+ properties: {
17
+ tool_id: { type: "string", description: "ID of an existing tool. Configure its connection privately in the PyAI console." },
18
+ enabled: { type: "boolean", default: true },
19
+ },
20
+ };
21
+ bindings.description = "Replace bindings using saved tool IDs. Existing settings are preserved for retained bindings. Credentials and connection settings are not accepted through MCP.";
22
+ }
23
+ return { ...t, inputSchema: schema, description: t.description + " Use saved tool IDs; configure connection settings privately in the PyAI console. Never supply credentials." };
24
+ });
25
+ }
26
+ export function withoutBindingConfig(value) {
27
+ if (Array.isArray(value))
28
+ return value.map(withoutBindingConfig);
29
+ if (!value || typeof value !== "object")
30
+ return value;
31
+ return Object.fromEntries(Object.entries(value).filter(([key]) => key !== "config")
32
+ .map(([key, entry]) => [key, withoutBindingConfig(entry)]));
33
+ }
package/dist/server.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
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.5.0";
3
+ import { TOOLS, HOSTED_TOOLS, TOOL_SCOPES, callTool, localContext, } from "./tools.js";
4
+ import { RESOURCES, workflowsForMode, guideForMode, instructionsForMode, } from "./workflows.js";
5
+ export const MCP_VERSION = "0.5.1";
6
6
  export function createMcpServer(client, context = localContext) {
7
+ const tools = context.mode === "http" ? HOSTED_TOOLS : TOOLS;
8
+ const workflows = workflowsForMode(context.mode);
7
9
  const server = new Server({
8
10
  name: "pyai",
9
11
  version: MCP_VERSION,
@@ -11,10 +13,10 @@ export function createMcpServer(client, context = localContext) {
11
13
  websiteUrl: "https://pyai.com/mcp",
12
14
  }, {
13
15
  capabilities: { tools: {}, resources: {}, prompts: {} },
14
- instructions: SERVER_INSTRUCTIONS,
16
+ instructions: instructionsForMode(context.mode),
15
17
  });
16
18
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
17
- tools: TOOLS.filter((t) => context.mode === "stdio" || t.name !== "create_sandbox_key").map((t) => context.mode === "stdio"
19
+ tools: tools.map((t) => context.mode === "stdio"
18
20
  ? t
19
21
  : {
20
22
  ...t,
@@ -36,7 +38,7 @@ export function createMcpServer(client, context = localContext) {
36
38
  }),
37
39
  }));
38
40
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
39
- if (!TOOLS.some((t) => t.name === request.params.name) ||
41
+ if (!tools.some((t) => t.name === request.params.name) ||
40
42
  (context.mode === "http" && request.params.name === "create_sandbox_key"))
41
43
  throw new McpError(ErrorCode.InvalidParams, "Unknown tool.");
42
44
  return (await callTool(request.params.name, request.params.arguments ?? {}, client, context));
@@ -53,13 +55,13 @@ export function createMcpServer(client, context = localContext) {
53
55
  {
54
56
  uri: resource.uri,
55
57
  mimeType: resource.mimeType,
56
- text: resource.uri === "pyai://guide" ? GUIDE : JSON.stringify(WORKFLOWS),
58
+ text: resource.uri === "pyai://guide" ? guideForMode(context.mode) : JSON.stringify(workflows),
57
59
  },
58
60
  ],
59
61
  };
60
62
  });
61
63
  server.setRequestHandler(ListPromptsRequestSchema, async () => ({
62
- prompts: Object.entries(WORKFLOWS).map(([name, w]) => ({
64
+ prompts: Object.entries(workflows).map(([name, w]) => ({
63
65
  name,
64
66
  title: w.title,
65
67
  description: w.example,
@@ -73,7 +75,7 @@ export function createMcpServer(client, context = localContext) {
73
75
  })),
74
76
  }));
75
77
  server.setRequestHandler(GetPromptRequestSchema, async (request) => {
76
- const workflow = WORKFLOWS[request.params.name];
78
+ const workflow = workflows[request.params.name];
77
79
  if (!workflow || !request.params.arguments?.task)
78
80
  throw new McpError(ErrorCode.InvalidParams, "Choose a workflow and provide task.");
79
81
  return {
package/dist/tools.js CHANGED
@@ -14,8 +14,10 @@ import { resolve, relative, isAbsolute, dirname, basename } from "node:path";
14
14
  import { Ajv } from "ajv";
15
15
  import { API_TOOLS } from "./generated-tools.js";
16
16
  import { EXTRA_TOOLS, EXTRA_SCOPES, extraTool } from "./extra-tools.js";
17
- import { GUIDE, WORKFLOWS, SEARCH_TERMS } from "./workflows.js";
17
+ import { SEARCH_TERMS } from "./workflows.js";
18
18
  import { PyAIError } from "./client.js";
19
+ import { hostedTools, AGENT_WRITE_TOOLS, withoutBindingConfig } from "./hosted-policy.js";
20
+ import { workflowsForMode, guideForMode } from "./workflows.js";
19
21
  import { openapiGet } from "./contract.js";
20
22
  const SPEECH_CALLING_GUIDE = "https://pyai.com/agents/speech-calling.md";
21
23
  export const localContext = {
@@ -437,7 +439,7 @@ export const TOOLS = [
437
439
  destructiveHint: ["configure_amd", "enable_recap"].includes(t.name),
438
440
  idempotentHint: readOnly.has(t.name),
439
441
  openWorldHint: !readOnly.has(t.name) &&
440
- !["synthesize_speech", "create_dub", "preview_cast_line", "transcribe_audio"].includes(t.name),
442
+ !["synthesize_speech", "preview_cast_line", "transcribe_audio"].includes(t.name),
441
443
  },
442
444
  _meta: {
443
445
  "pyai/docs_url": SPEECH_CALLING_GUIDE,
@@ -446,8 +448,10 @@ export const TOOLS = [
446
448
  : [{ type: "noauth" }],
447
449
  },
448
450
  }));
451
+ export const HOSTED_TOOLS = hostedTools(TOOLS);
449
452
  const ajv = new Ajv({ strict: false, allErrors: true, validateFormats: false });
450
453
  const validators = new Map(TOOLS.map((t) => [t.name, ajv.compile(t.inputSchema)]));
454
+ const hostedValidators = new Map(HOSTED_TOOLS.map(t => [t.name, ajv.compile(t.inputSchema)]));
451
455
  function str(args, key) {
452
456
  const v = args[key];
453
457
  return typeof v === "string" && v.length > 0 ? v : undefined;
@@ -467,10 +471,11 @@ export async function callTool(name, args, client, ctx = localContext) {
467
471
  } };
468
472
  }
469
473
  async function executeTool(name, args, client, ctx) {
470
- const tool = TOOLS.find((t) => t.name === name);
474
+ const availableTools = ctx.mode === "http" ? HOSTED_TOOLS : TOOLS;
475
+ const tool = availableTools.find((t) => t.name === name);
471
476
  if (!tool)
472
477
  return toolError(`Unknown tool: ${name}`, "unknown_tool");
473
- const validator = validators.get(name);
478
+ const validator = (ctx.mode === "http" ? hostedValidators : validators).get(name);
474
479
  if (!validator(args))
475
480
  return toolError(`Invalid arguments: ${ajv.errorsText(validator.errors, { separator: "; " })}`, "invalid_arguments");
476
481
  if (name === "create_sandbox_key" && ctx.mode === "http")
@@ -510,7 +515,8 @@ async function executeTool(name, args, client, ctx) {
510
515
  "i",
511
516
  "want",
512
517
  ].includes(w)) ?? [];
513
- const matches = Object.entries(WORKFLOWS)
518
+ const workflows = workflowsForMode(ctx.mode);
519
+ const matches = Object.entries(workflows)
514
520
  .map(([key, w]) => ({
515
521
  key,
516
522
  w,
@@ -523,11 +529,11 @@ async function executeTool(name, args, client, ctx) {
523
529
  .sort((a, b) => b.score - a.score);
524
530
  const selected = matches.length
525
531
  ? matches
526
- : Object.entries(WORKFLOWS).map(([key, w]) => ({ key, w, score: 0 }));
532
+ : Object.entries(workflows).map(([key, w]) => ({ key, w, score: 0 }));
527
533
  const names = new Set(selected.flatMap((m) => [...m.w.tools]));
528
534
  return text({
529
535
  workflows: selected.map(({ key, w }) => ({ product: key, ...w })),
530
- tools: TOOLS.filter((t) => names.has(t.name)).map((t) => ({
536
+ tools: availableTools.filter((t) => names.has(t.name)).map((t) => ({
531
537
  name: t.name,
532
538
  description: t.description,
533
539
  })),
@@ -553,7 +559,20 @@ async function executeTool(name, args, client, ctx) {
553
559
  for (const key of generated.route.query)
554
560
  if (args[key] !== undefined)
555
561
  query.set(key, String(args[key]));
556
- return text(await client.json(generated.route.method, path + (query.size ? "?" + query.toString() : ""), args.body, "idempotency" in generated.route ? { "Idempotency-Key": String(args.idempotency_key) } : undefined));
562
+ let requestBody = args.body;
563
+ if (ctx.mode === "http" && AGENT_WRITE_TOOLS.has(name) && name !== "create_agent") {
564
+ const bindings = name === "set_agent_tools" ? args.body : args.body?.tools;
565
+ if (Array.isArray(bindings)) {
566
+ // Fetch privately: do not route saved connection settings through model context.
567
+ const existing = await client.getJson(`/v1/agents/${encodeURIComponent(String(args.id))}`);
568
+ const saved = new Map((existing.tools ?? []).map(b => [b.tool_id, b.config]));
569
+ const preserved = bindings.map((b) => ({ ...b, ...(saved.has(b.tool_id) ? { config: saved.get(b.tool_id) } : {}) }));
570
+ requestBody = name === "set_agent_tools" ? preserved : { ...args.body, tools: preserved };
571
+ }
572
+ }
573
+ const result = await client.json(generated.route.method, path + (query.size ? "?" + query.toString() : ""), requestBody, "idempotency" in generated.route ? { "Idempotency-Key": String(args.idempotency_key) } : undefined);
574
+ return text(ctx.mode === "http" && ["list_agents", "get_agent", ...AGENT_WRITE_TOOLS].includes(name)
575
+ ? withoutBindingConfig(result) : result);
557
576
  }
558
577
  const extra = await extraTool(name, args, client, ctx);
559
578
  if (extra)
@@ -562,7 +581,7 @@ async function executeTool(name, args, client, ctx) {
562
581
  case "openapi_get":
563
582
  return text(await openapiGet(client, args));
564
583
  case "get_started":
565
- return text(GUIDE);
584
+ return text(guideForMode(ctx.mode));
566
585
  case "create_sandbox_key": {
567
586
  const label = str(args, "label");
568
587
  const result = await client.postJson("/v1/sandbox/keys", label ? { label } : {});
package/dist/workflows.js CHANGED
@@ -144,3 +144,22 @@ export const SEARCH_TERMS = {
144
144
  dub: "dubbing translate translation languages audio",
145
145
  cast: "narration direct expressive script emotion podcast",
146
146
  };
147
+ // Keep every hosted discovery surface consistent with the callable catalog.
148
+ export function workflowsForMode(mode) {
149
+ if (mode === "stdio")
150
+ return WORKFLOWS;
151
+ return { ...WORKFLOWS, calling: { ...WORKFLOWS.calling,
152
+ tools: WORKFLOWS.calling.tools.filter(name => name !== "telephony_buy_number"),
153
+ steps: [WORKFLOWS.calling.steps[0],
154
+ "Use an already-owned number. Number purchasing is unavailable through hosted MCP. Binding changes routing; outbound calls require explicit destination approval, confirm=true and a stable idempotency_key.",
155
+ ...WORKFLOWS.calling.steps.slice(2)],
156
+ } };
157
+ }
158
+ export function guideForMode(mode) {
159
+ return mode === "stdio" ? GUIDE : GUIDE + "\nHosted MCP does not purchase numbers or accept connection credentials. Use saved tool IDs; configure connections privately in the PyAI console. Supply only recordings and text you are authorized to process. Do not submit payment-card data, protected health information, government identifiers, or authentication secrets.\n";
160
+ }
161
+ export function instructionsForMode(mode) {
162
+ return mode === "stdio" ? SERVER_INSTRUCTIONS : SERVER_INSTRUCTIONS
163
+ .replace("purchases and real outbound calls need explicit authorization", "real outbound calls need explicit destination authorization; number purchasing is unavailable here")
164
+ .replace(" Current pricing: https://pyai.com/pricing.", " Configure connection credentials privately in the PyAI console; use saved tool IDs here. Do not request restricted personal data or direct users to checkout.");
165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyai/mcp",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "PyAI MCP: OAuth-connected voice agents, speech, transcription, compliance, call summaries, dubbing and expressive narration for chat apps and coding agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",