ApiLogicServer 15.0.0__py3-none-any.whl → 15.0.10__py3-none-any.whl

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.
Files changed (37) hide show
  1. api_logic_server_cli/add_cust/add_cust.py +8 -2
  2. api_logic_server_cli/api_logic_server.py +2 -2
  3. api_logic_server_cli/api_logic_server_info.yaml +3 -3
  4. api_logic_server_cli/create_from_model/__pycache__/dbml.cpython-312.pyc +0 -0
  5. api_logic_server_cli/create_from_model/dbml.py +1 -0
  6. api_logic_server_cli/genai/genai_svcs.py +5 -2
  7. api_logic_server_cli/manager.py +1 -0
  8. api_logic_server_cli/prototypes/base/api/api_discovery/mcp_discovery.py +63 -24
  9. api_logic_server_cli/prototypes/base/config/logging.yml +5 -0
  10. api_logic_server_cli/prototypes/base/config/server_setup.py +73 -0
  11. api_logic_server_cli/prototypes/base/integration/mcp/examples/mcp_discovery_response.json +150 -0
  12. api_logic_server_cli/prototypes/base/integration/mcp/examples/mcp_request.prompt +46 -0
  13. api_logic_server_cli/prototypes/base/integration/mcp/examples/mcp_tool_context_response.json +34 -0
  14. api_logic_server_cli/prototypes/base/integration/mcp/examples/mcp_tool_context_response_get.json +18 -0
  15. api_logic_server_cli/prototypes/base/integration/mcp/mcp_client_executor.py +395 -203
  16. api_logic_server_cli/prototypes/basic_demo/customizations/logic/logic_discovery/mcp_client_executor_request.py +11 -282
  17. api_logic_server_cli/prototypes/basic_demo/customizations/ui/admin/admin.yaml +3 -3
  18. api_logic_server_cli/prototypes/basic_demo/customizations/ui/admin/home.js +48 -0
  19. api_logic_server_cli/prototypes/manager/system/genai/mcp_learning/mcp.prompt +12 -0
  20. {apilogicserver-15.0.0.dist-info → apilogicserver-15.0.10.dist-info}/METADATA +1 -1
  21. {apilogicserver-15.0.0.dist-info → apilogicserver-15.0.10.dist-info}/RECORD +26 -32
  22. api_logic_server_cli/prototypes/base/integration/mcp/README_mcp.md +0 -15
  23. api_logic_server_cli/prototypes/base/integration/mcp/test_notes.txt +0 -37
  24. api_logic_server_cli/prototypes/basic_demo/customizations/api/api_discovery/mcp_discovery.py +0 -96
  25. api_logic_server_cli/prototypes/basic_demo/customizations/config/server_setup.py +0 -388
  26. api_logic_server_cli/prototypes/basic_demo/customizations/integration/mcp/.DS_Store +0 -0
  27. api_logic_server_cli/prototypes/basic_demo/customizations/integration/mcp/README_mcp.md +0 -15
  28. api_logic_server_cli/prototypes/basic_demo/customizations/integration/mcp/Zmcp_client_executor.py +0 -294
  29. api_logic_server_cli/prototypes/basic_demo/customizations/integration/mcp/mcp_schema.txt +0 -47
  30. api_logic_server_cli/prototypes/basic_demo/customizations/integration/mcp/mcp_server_discovery.json +0 -9
  31. api_logic_server_cli/prototypes/basic_demo/customizations/integration/mcp/mcp_tool_context.json +0 -25
  32. api_logic_server_cli/prototypes/basic_demo/customizations/integration/mcp/test_notes.txt +0 -37
  33. /api_logic_server_cli/prototypes/base/integration/mcp/{mcp_schema.txt → examples/mcp_schema.txt} +0 -0
  34. {apilogicserver-15.0.0.dist-info → apilogicserver-15.0.10.dist-info}/WHEEL +0 -0
  35. {apilogicserver-15.0.0.dist-info → apilogicserver-15.0.10.dist-info}/entry_points.txt +0 -0
  36. {apilogicserver-15.0.0.dist-info → apilogicserver-15.0.10.dist-info}/licenses/LICENSE +0 -0
  37. {apilogicserver-15.0.0.dist-info → apilogicserver-15.0.10.dist-info}/top_level.txt +0 -0
@@ -1,56 +1,83 @@
1
1
  """
2
- This simulates the MCP Client Executor,
3
- which takes a natural language query and converts it into a tool context block:
4
-
5
- 1. Discovers MCP servers (from config)
6
- 2. Queries OpenAI's GPT-4 model to obtain the tool context based on a provided schema and a natural language query
2
+ A basic MCP Client Executor: takes a natural language query and:
3
+ 1. Discovers MCP servers (from mcp_server_discovery.json)
4
+ 2. Queries OpenAI's GPT-4 model to obtain the tool context, based on a provided schema and a natural language query
7
5
  3. Processes the tool context (calls the indicated MCP (als) endpoints)
8
6
 
9
- Notes:
10
- * See: integration/mcp/README_mcp.md
11
- * python api_logic_server_run.py
7
+ To run:
8
+ 1. Start the server (F5), and:
9
+ 2. Run this:
10
+ 1. Terminal: python integration/mcp/mcp_client_executor.py
11
+ 2. Or, if you have installed SysMcp:
12
+ 1. curl -X 'POST' 'http://localhost:5656/api/SysMcp/' -H 'accept: application/vnd.api+json' -H 'Content-Type: application/json' -d '{ "data": { "attributes": {"request": "List the orders date_shipped is null and CreatedOn before 2023-07-14, and send a discount email (subject: '\''Discount Offer'\'') to the customer for each one."}, "type": "SysMcp"}}'
13
+ 2. Or, open the Admin App:
14
+ * List the orders date_shipped is null and CreatedOn before 2023-07-14, and send a discount email (subject: 'Discount Offer') to the customer for each one.
12
15
 
16
+ See: https://apilogicserver.github.io/Docs/Integration-MCP/
13
17
  """
14
18
 
15
- import json
16
- import os, sys
19
+ ################
20
+ # debug settings
21
+ ################
22
+
23
+ create_tool_context_from_llm = False
24
+ ''' set to False to bypass LLM call and save 2-3 secs in testing, no API Key required. '''
25
+
26
+ import os, logging, logging.config, sys
27
+ from pathlib import Path
17
28
  from typing import Dict, List
29
+ import yaml
30
+
31
+ mcp_path = Path(os.path.abspath(os.path.dirname(__file__)))
32
+ project_path = mcp_path.parent.parent
33
+ sys.path.append(str(project_path)) # add project root to sys.path
34
+
35
+ import re
36
+ import json
37
+ from openai import OpenAIError
18
38
  import openai
19
39
  import requests
20
40
  from logic_bank.logic_bank import Rule
41
+ from logic_bank.exec_row_logic.logic_row import LogicRow
42
+ from database import models
21
43
  from logic_bank.util import ConstraintException
22
44
 
23
45
  # Set your OpenAI API key
24
46
  openai.api_key = os.getenv("APILOGICSERVER_CHATGPT_APIKEY")
25
47
 
26
- server_url = os.getenv("APILOGICSERVER_URL", "http://localhost:5656/api")
48
+ log = logging.getLogger('integration.mcp')
27
49
 
28
- # debug settings
29
- test_type = 'orchestration' # 'simple_get' or 'orchestration'
30
- create_tool_context_from_llm = True
31
- ''' set to False to bypass LLM call and save 2-3 secs in testing '''
32
- use_test_schema = False
33
- ''' True means bypass discovery, use hard-coded schedma file '''
34
-
35
- def discover_mcp_servers():
36
- """ Discover the MCP servers by calling the /api/.well-known/mcp.json endpoint.
37
- This function retrieves the list of available MCP servers and their capabilities.
38
- """
39
- global server_url, use_test_schema
50
+ default_query = "List customers with credit_limit > 1000."
51
+ default_query_email = "List the orders date_shipped is null and CreatedOn before 2023-07-14, and send a discount email (subject: 'Discount Offer') to the customer for each one."
52
+
53
+ def discover_mcp_servers() -> str:
54
+ """Discover MCP servers (aka 'tools'), and retrieve their API learnings and schemas.
55
+ This function performs the following steps:
56
+ 1. Reads a configuration file (`integration/mcp/mcp_server_discovery.json`) to obtain a list of available MCP servers.
57
+ 2. For each server, calls its `schema_url` endpoint to retrieve the MCP learnings_and_schema.
58
+ See: .well-known/mcp.json (see api/api_discovery/mcp_discovery.py)
59
+ 3. Logs the discovered servers and their schemas for informational purposes.
40
60
 
41
- # create schema_text (for prompt), by reading integration/mcp/mcp_schema.txt
61
+ Raises:
62
+ FileNotFoundError: If the discovery configuration file is not found.
63
+ json.JSONDecodeError: If the configuration file contains invalid JSON.
64
+ requests.RequestException: If there is an error making HTTP requests to the schema URLs.
65
+
66
+ Returns:
67
+ learnings_and_schema: str
68
+ """
42
69
 
43
- # find the servers - read the mcp_server_discovery.json file
44
70
  discovery_file_path = os.path.join(os.path.dirname(__file__), "../../integration/mcp/mcp_server_discovery.json")
45
71
  try:
46
72
  with open(discovery_file_path, "r") as discovery_file:
47
73
  discovery_data = json.load(discovery_file)
48
- print(f"\n1. Discovered MCP servers from config file: {discovery_file_path}:" + json.dumps(discovery_data, indent=4))
74
+ log.info(f"\n1. Discovered MCP servers from config file: {discovery_file_path}:" + json.dumps(discovery_data, indent=4))
49
75
  except FileNotFoundError:
50
- print(f"Discovery file not found at {discovery_file_path}.")
76
+ log.info(f"Discovery file not found at {discovery_file_path}.")
51
77
  except json.JSONDecodeError as e:
52
- print(f"Error decoding JSON from {discovery_file_path}: {e}")
78
+ log.info(f"Error decoding JSON from {discovery_file_path}: {e}")
53
79
 
80
+ api_schema = {} # initialize api_schema to an empty dict
54
81
  for each_server in discovery_data["servers"]:
55
82
  discovery_url = each_server["schema_url"]
56
83
 
@@ -58,53 +85,40 @@ def discover_mcp_servers():
58
85
  try:
59
86
  response = requests.get(discovery_url)
60
87
  if response.status_code == 200:
61
- api_schema = response.json()
62
- print()
63
- request_print = json.dumps(api_schema, indent=4)[0:400] + '\n... etc' # limit for readability
64
- print(f"\n\nAPI Schema from discovery schema_url: {discovery_url}:\n" + request_print)
88
+ each_schema = response.json()
89
+ api_schema[discovery_url] = each_schema
90
+ if format_for_print := False:
91
+ each_schema["learning"] = each_schema['learning'].split('\n') # split learning into a list of lines
92
+ request_print = json.dumps(each_schema, indent=4)[0:1200] # limit for readability
93
+ request_print_schema = json.dumps(each_schema.get("resources", {}), indent=4)[0:200] + '\n... etc'
94
+ log.info(f"\n\nLearnings and Schema from discovery schema_url: {discovery_url}:\n" + request_print)
95
+ log.info(f' "resources":\n' + request_print_schema)
65
96
  else:
66
- print(f"Failed to retrieve API schema from {discovery_url}: {response.status_code}")
97
+ log.info(f"Failed to retrieve API schema from {discovery_url}: {response.status_code}")
67
98
  except requests.RequestException as e:
68
- print(f"Error calling OpenAPI URL: {e}")
99
+ log.info(f"Error calling OpenAPI URL: {e}")
100
+ pass
101
+ debug_print = json.dumps(api_schema, indent=4)
69
102
  return json.dumps(api_schema)
70
103
 
71
104
 
72
- def get_user_nl_query_and_training(query: str):
73
- """ Get the natural language query from the user.
74
- Add training for the LLM to generate a tool context block.
75
-
76
- """
77
-
78
- global test_type
79
- # read file docs/mcp_learning/mcp.prompt
80
- prompt_file_path = os.path.join(os.path.dirname(__file__), "../../docs/mcp_learning/mcp.prompt")
81
- if os.path.exists(prompt_file_path):
82
- with open(prompt_file_path, "r") as prompt_file:
83
- training_prompt = prompt_file.read()
84
- # print(f"\nLoaded training prompt from {prompt_file_path}:\n{training_prompt}")
85
- else:
86
- training_prompt = ""
87
- print(f"Prompt file not found at {prompt_file_path}.")
88
-
89
- # if 1 argument, use it as the query
90
- query_actual = query
91
- if len(sys.argv) > 1:
92
- query_actual = sys.argv[1]
93
- if query_actual == '':
94
- query_actual = "list customers with balance over 100."
95
- return query_actual + ";\n\n" + training_prompt
96
-
97
105
 
98
- def query_llm_with_nl(schema_text, nl_query):
106
+ def query_llm_with_nl(learnings_and_schema: str, nl_query: str):
99
107
  """
100
108
  Query the LLM with a natural language query and schema text to generate a tool context block.
101
109
 
110
+ This returns a string like:
111
+ Natural language query:
112
+ List the orders date_shipped is null and CreatedOn before 2023-07-14, and send a discount email (subject: 'Discount Offer') to the customer for each one.
113
+ <docs/mcp_learning/mcp.prompt>
114
+ <docs/mcp_learning/mcp_schema.txt>
115
+
102
116
  It handles both orchestration and simple GET requests.
103
117
  """
104
118
 
105
- global test_type, create_tool_context_from_llm
119
+ global create_tool_context_from_llm
106
120
 
107
- content = f"Natural language query:\n {nl_query}\nSchema:\n{schema_text}"
121
+ content = f"Natural language query:\n {nl_query}\n\nLearnings_and_Schema:\n{learnings_and_schema}"
108
122
  messages = [
109
123
  {
110
124
  "role": "system",
@@ -116,111 +130,102 @@ def query_llm_with_nl(schema_text, nl_query):
116
130
  }
117
131
  ]
118
132
 
119
- request_print = content[0:1200] + '\n... etc' # limit for readability
120
- print("\n\n2a. LLM request:\n", request_print)
121
- # print("\n2b. NL Query:\n", nl_query)
122
- # print("\n2c. schema_text: (truncated) \n")
123
- # schema_print = json.dumps(json.loads(schema_text), indent=4)[:400] # limit for readability
124
- # print(schema_print)
133
+ request_print = content[0:1400] + '\n... etc from step 1' # limit for readability
134
+ log.debug("\n\n\n2a. LLM request:\n\n" + request_print)
135
+ schema_print = json.dumps(json.loads(learnings_and_schema), indent=4)[:400] # limit for readability
136
+ # log.debug(schema_print)
125
137
 
126
138
  if create_tool_context_from_llm: # takes 2-3 seconds...
127
- response = openai.chat.completions.create(
128
- model="gpt-4",
129
- messages=messages,
130
- temperature=0.2
131
- )
132
-
133
- tool_context_str = response.choices[0].message.content
134
- tool_context_str_no_cr = tool_context_str.replace("\n", '') # convert single quotes to double quotes
135
- try:
136
- tool_context = json.loads(tool_context_str_no_cr)
137
- except json.JSONDecodeError:
138
- print("Failed to decode JSON from response:", tool_context_str)
139
- return None
140
-
141
- print("\n2d. generated tool context from LLM:\n", json.dumps(tool_context, indent=4))
139
+ response = openai.chat.completions.create(
140
+ model="gpt-4",
141
+ messages=messages,
142
+ temperature=0.2
143
+ )
144
+ tool_context_str = response.choices[0].message.content
145
+ else:
146
+ # read integration/mcp/mcp_tool_context.json
147
+ tool_context_file_path = os.path.join(os.path.dirname(__file__), "../../integration/mcp/examples/mcp_tool_context_response_get.json")
148
+ if nl_query == default_query_email:
149
+ tool_context_file_path = os.path.join(os.path.dirname(__file__), "../../integration/mcp/examples/mcp_tool_context_response.json")
150
+ try:
151
+ with open(tool_context_file_path, "r") as tool_context_file:
152
+ tool_context_str = tool_context_file.read()
153
+ # log.info(f"\n\n2c. Tool context from file {tool_context_file_path}:\n" + tool_context_str)
154
+ except FileNotFoundError:
155
+ raise ConstraintException(f"Tool context file not found at {tool_context_file_path}.")
156
+
157
+
158
+ tool_context_str_no_cr = tool_context_str.replace("\n", '') # convert single quotes to double quotes
159
+ try:
160
+ tool_context = json.loads(tool_context_str_no_cr)
161
+ except json.JSONDecodeError:
162
+ print("Failed to decode JSON from response:\n" + tool_context_str)
163
+ return None
164
+
165
+ log.info(f"\n2b. generated tool context from LLM:\n" + json.dumps(tool_context, indent=4))
142
166
 
143
167
  if "resources" not in tool_context:
144
168
  raise ConstraintException("GenAI Error - LLM response does not contain 'resources'.")
145
169
  return tool_context
146
170
 
147
171
 
148
- def process_tool_context(tool_context):
149
- """ Process the orchestration request by executing multiple tool context blocks.
150
- This executes the tool context blocks against a live JSON:API server.
151
- It handles both GET and POST requests, and it can
152
- orchestrate multiple requests based on the provided tool context.
153
172
 
154
- Note the orchestration is processed by the client executor (here), not the server executor.
173
+ def process_tool_context(tool_context):
155
174
 
156
- Research:
175
+ log.info("\n3. MCP Client Executor – Starting Tool Context Execution\n")
176
+ context_results = []
177
+ ''' results from each step are appended to this list,
178
+ which is used to resolve variables in subsequent steps. '''
157
179
 
158
- 1. How is this a "USB", since the request was specific about JSON:API?
159
- 2. How is it clear to loop through the tool_context[0] and call tool_context[1]?
160
- """
161
- global server_url
162
180
 
163
181
  def get_query_param_filter(query_params):
164
- """ return json:api filter
165
-
166
- eg
167
- curl -qg 'http://localhost:5656/api/Order?filter=[{"name":"date_shipped","op":"eq","val":null},{"name":"CreatedOn","op":"lt","val":"2023-07-14"}]'
168
-
169
- curl -qg 'http://localhost:5656/api/Order?filter=[{"name":"date_shipped","op":"gt","val":"2023-07-14"}]'
170
- curl -qg 'http://localhost:5656/api/Order?filter=[{"name":"date_shipped","op":"eq","val":null}]'
171
- curl -qg 'http://localhost:5656/api/Customer?filter=[{"name":"credit_limit","op":"gt","val":"1000"}]'
172
-
173
- query_params might be simple:
174
- "query_params": [ {"name": "credit_limit", "op": "gt", "val": "1000"} ]
175
- ==> ?filter=[{"name":"credit_limit","op":"gt","val":"1000"}]
176
-
177
- or a list:
178
- "query_params": [
179
- {
180
- "name": "date_shipped",
181
- "op": "eq",
182
- "val": None
183
- },
184
- {
185
- "name": "date_created",
186
- "op": "lt",
187
- "val": "2023-07-14"
188
- }
189
- ],
190
-
191
- """
192
-
193
- added_rows = 0
194
-
195
- query_param_filter = ''
196
- assert isinstance(query_params, list), "Query Params filter expected to be a list"
197
- query_param_filter = 'filter=' + str(query_params)
198
- # use urlencode to convert to JSON:API format...
199
- # val urllib.parse.quote() or urllib.parse.urlencode()
200
- # tool instructions... filtering, email etc "null"
201
- query_param_filter = query_param_filter.replace("'", '"') # convert single quotes to double quotes
202
- query_param_filter = query_param_filter.replace("None", 'null')
203
- query_param_filter = query_param_filter.replace('"null"', 'null')
204
- # query_param_filter = query_param_filter.replace("date_created", 'CreatedOn') # TODO - why this name?
205
- return query_param_filter # end get_query_param_filter
206
-
207
- def move_fields(src: dict, dest: dict, context_data: dict):
208
- """ Move fields from src to dest, replacing any variables with their values from context_data."""
209
- for variable_name, value in src.items():
210
- move_value = value
211
- if move_value.startswith("{") and move_value.endswith("}"):
212
- # strip the braces, and get the name after the first dot, # eg: "{Order.customer_id}" ==> "customer_id"``
213
- move_name = move_value[1:-1] # strip the braces
214
- if '.' in move_value:
215
- move_name = move_name.split('.', 1)[1]
216
- move_value = context_data['attributes'][move_name]
217
- dest[variable_name] = move_value
218
- return dest
182
+ """ return json:api filter
183
+
184
+ eg
185
+ curl -qg 'http://localhost:5656/api/Order?filter=[{"name":"date_shipped","op":"eq","val":null},{"name":"CreatedOn","op":"lt","val":"2023-07-14"}]'
186
+
187
+ curl -qg 'http://localhost:5656/api/Order?filter=[{"name":"date_shipped","op":"gt","val":"2023-07-14"}]'
188
+ curl -qg 'http://localhost:5656/api/Order?filter=[{"name":"date_shipped","op":"eq","val":null}]'
189
+ curl -qg 'http://localhost:5656/api/Customer?filter=[{"name":"credit_limit","op":"gt","val":"1000"}]'
190
+
191
+ query_params might be simple:
192
+ "query_params": [ {"name": "credit_limit", "op": "gt", "val": "1000"} ]
193
+ ==> ?filter=[{"name":"credit_limit","op":"gt","val":"1000"}]
194
+
195
+ or a list:
196
+ "query_params": [
197
+ {
198
+ "name": "date_shipped",
199
+ "op": "eq",
200
+ "val": None
201
+ },
202
+ {
203
+ "name": "date_created",
204
+ "op": "lt",
205
+ "val": "2023-07-14"
206
+ }
207
+ ],
208
+
209
+ """
210
+
211
+ added_rows = 0
212
+
213
+ query_param_filter = ''
214
+ assert isinstance(query_params, list), "Query Params filter expected to be a list"
215
+ query_param_filter = 'filter=' + str(query_params)
216
+ # use urlencode to convert to JSON:API format...
217
+ # val urllib.parse.quote() or urllib.parse.urlencode()
218
+ # tool instructions... filtering, email etc "null"
219
+ query_param_filter = query_param_filter.replace("'", '"') # convert single quotes to double quotes
220
+ query_param_filter = query_param_filter.replace("None", 'null')
221
+ query_param_filter = query_param_filter.replace('"null"', 'null')
222
+ # query_param_filter = query_param_filter.replace("date_created", 'CreatedOn') # TODO - why this name?
223
+ return query_param_filter # end get_query_param_filter
219
224
 
220
225
  def print_get_response(query_param_filter, mcp_response):
221
226
  """ Print the response from the GET request. """
222
- print("\n3. MCP Server (als) GET filter(query_param_filter):\n", query_param_filter)
223
- print(" GET Response:\n", mcp_response.text)
227
+ log.info("\n3. MCP Server (als) GET filter(query_param_filter):\n" + query_param_filter)
228
+ log.info(" GET Response:\n" + mcp_response.text)
224
229
  results : List[Dict] = mcp_response.json()['data']
225
230
  # print results in a table format
226
231
  if results:
@@ -231,66 +236,253 @@ def process_tool_context(tool_context):
231
236
  keys.update(row.keys())
232
237
  keys = list(keys)
233
238
  # Print header
234
- print("\n| " + " | ".join(keys) + " |")
235
- print("|" + "|".join(["---"] * len(keys)) + "|")
239
+ log.info("\n| " + " | ".join(keys) + " |")
240
+ log.info("|" + "|".join(["---"] * len(keys)) + "|")
236
241
  # Print rows
237
242
  for row in results:
238
- print("| " + " | ".join(str(row.get(k, "")) for k in keys) + " |")
243
+ log.info("| " + " | ".join(str(row.get(k, "")) for k in keys) + " |")
239
244
  else:
240
- print("No results found.")
241
-
242
- assert isinstance(tool_context, (dict, list)), "Tool context expected to be a dictionary"
243
- context_data = {}
244
- added_rows = 0
245
-
246
- for each_block in tool_context["resources"]:
247
- if process_tool_context := True:
248
- if each_block["method"] == "GET":
249
- query_param_filter = get_query_param_filter(each_block["query_params"])
250
- headers = {"Content-Type": "application/vnd.api+json"}
251
- if "headers" in each_block:
252
- headers.update(each_block["headers"])
253
- mcp_response = requests.get(
254
- url = each_block["base_url"] + each_block["path"],
255
- headers=headers,
256
- params=query_param_filter
257
- )
258
- context_data = mcp_response.json()['data'] # result rows...
259
- print_get_response(query_param_filter, mcp_response)
260
- elif each_block["method"] in ["POST"]:
261
- for each_order in context_data:
262
- url = each_block["base_url"] + each_block["path"]
263
- json_update_data = { 'data': {"type": "Email", 'attributes': {} } }
264
- json_update_data_attributes = json_update_data["data"]["attributes"]
265
- move_fields( src= each_block["body"], dest=json_update_data_attributes, context_data=each_order)
266
- # eg: POST http://localhost:5656/api/Email {'data': {'type': 'Email', 'attributes': {'customer_id': 5, 'message': {'to': '{{ order.customer_id }}', 'subject': 'Discount for your order', 'body': 'Dear customer, you have a discount for your recent order. Thank you for shopping with us.'}}}}
267
- headers = {"Content-Type": "application/vnd.api+json"}
268
- if "headers" in each_block:
269
- headers.update(each_block["headers"])
270
- mcp_response = requests.post(
271
- url=url,
272
- headers=headers,
273
- json=json_update_data
274
- )
275
- added_rows += 1
276
- pass
277
- print("\n3. MCP Server (als) POST Response:\n", mcp_response.text)
278
- if added_rows > 0:
279
- print(f"...Added {added_rows} rows to the database; last row (only) shown above.")
280
- return mcp_response
245
+ log.info("No results found.")
281
246
 
247
+ def substitute_vars(val, context, row=None, ref_index=None):
248
+ """
249
+ Substitutes variable references in a value using a provided context.
250
+
251
+ If `val` is a string starting with '$', attempts to parse it as a variable reference
252
+ of the form '$<step_idx>[*].<attr>' or '$<step_idx>.<attr>'. Retrieves the corresponding
253
+ value from the `context` list or from the `row` dictionary if the reference index matches.
254
+
255
+ Args:
256
+ val (Any): The value to substitute. If not a string or not a variable reference, returned as-is.
257
+ reference example: '$0[*].customer_id' or '$1.email'
258
+ context (list): A list of dictionaries or objects used for variable substitution.
259
+ The result list from prior step
260
+ Each item in the list is expected to be a dictionary with attributes that can be accessed.
261
+ row (dict, optional): A dictionary representing the current row, used if the reference index matches `ref_index`.
262
+ The current row (eg, order) dictionary for variable substitution.
263
+ ref_index (int, optional): The index to compare against the variable reference for row substitution.
264
+
265
+ Returns:
266
+ Any: The substituted value if a variable reference is found and resolved, otherwise the original value.
267
+ """
268
+ if isinstance(val, str) and val.startswith("$"):
269
+ match = re.match(r"\$(\d+)(\[\*\])?\.(\w+)", val)
270
+ if not match:
271
+ return val
272
+ step_idx, star, attr = match.groups()
273
+ step_idx = int(step_idx)
274
+ if enabled_fix_me := False and star: # TODO: fix this disabled code
275
+ return context[step_idx]
276
+ if row is not None and step_idx == ref_index:
277
+ return row['attributes'][attr] if attr in row['attributes'] else row.get(attr)
278
+ return context[step_idx].get(attr)
279
+ return val
280
+
281
+ def resolve_step(step, context, row=None, ref_index=None):
282
+ """
283
+ Resolves variables in the 'body' and 'query_params' fields of a step dictionary using the provided context, row, and ref_index.
284
+
285
+ Args:
286
+ step (dict): The step dictionary containing 'body' and 'query_params' fields, each as a list of field dictionaries.
287
+ context (dict): The context dictionary used for variable substitution. eg, the orders
288
+ row (dict, optional): An optional source row (eg, order) dictionary for variable substitution.
289
+ ref_index (int, optional): An optional reference index for variable substitution. Defaults to None.
290
+
291
+ Returns:
292
+ dict: A copy of the step dictionary with variables in 'body' and 'query_params' fields resolved.
293
+ """
294
+
295
+ def resolve_field_list(field_list):
296
+ """
297
+ Resolves a list of field dictionaries by substituting variables in their 'value' fields.
298
+
299
+ Each field in the input list is expected to be a dictionary containing a 'value' key.
300
+ The function applies the substitute_vars function to the 'value' of each field,
301
+ using the provided context, row, and ref_index, and returns a new list of fields
302
+ with the substituted values.
303
+
304
+ Args:
305
+ field_list (list of dict): A list of field dictionaries (eg, email post row), each containing at least a 'value' key, eg
306
+ {'subject': 'Discount Offer', 'message': 'You have a new discount offer', 'customer_id': '$0[*].customer_id'}
307
+
308
+ Returns:
309
+ list of dict: A new list of field dictionaries with the 'value' field updated after variable substitution.
310
+ """
311
+
312
+ # return dict(f, value=substitute_vars(f.get("value"), context, row, ref_index)) for f in field_list
313
+ resolved_fields = []
314
+ for field_name, field_value in field_list.items():
315
+ resolved_field = {}
316
+ resolved_field[field_name] = substitute_vars(field_value, context, row, ref_index)
317
+ resolved_fields.append(resolved_field)
318
+ return resolved_fields
319
+
320
+ step_copy = {**step}
321
+ step_copy["body"] = resolve_field_list(step.get("body", []))
322
+ if "query_params" in step_copy:
323
+ step_copy["query_params"] = resolve_field_list(step.get("query_params", []))
324
+ return step_copy
325
+
326
+ def find_fan_out_key(step):
327
+ """
328
+ Fan-out means that the step has a key pattern like '$<number>[*].<field_name>',
329
+ so the action (eg, send mail) is repeated for each item (eg, order) in the list at 'context[<number>]'.
330
+
331
+ Searches for a fan-out key pattern in the 'body' of the given step.
282
332
 
283
- if __name__ == "__main__":
333
+ The function iterates over the fields in the 'body' of the step dictionary,
334
+ looking for a field whose 'value' is a string containing the pattern '[*]'.
335
+ If such a pattern is found and matches the format '$<number>[*].<field_name>',
336
+ it extracts and returns the number and field name as a tuple.
284
337
 
285
- # to run: Run Config > Run designated Python file
338
+ Args:
339
+ step (dict): A dictionary representing a step, expected to have a 'body' key
340
+ containing a list of field dictionaries with a 'value' key.
286
341
 
287
- schema_text = discover_mcp_servers() # see: 1-discovery-from-als
342
+ {.. 'body': {'subject': 'Discount Offer', 'message': 'You have a new discount offer', 'customer_id': '$0[*].customer_id'}
343
+
344
+ Returns:
345
+ tuple[int, str] or None: A tuple containing the integer index and the field name
346
+ if a matching pattern is found, otherwise None.
347
+ """
348
+ if 'body' in step:
349
+ body = step.get("body", [])
350
+ # iterate the body fields / values
351
+ # This loop checks each field in the body for a fan-out pattern
352
+ for attr_name, attr_value in body.items():
353
+ if isinstance(attr_value, str) and "[*]" in attr_value:
354
+ match = re.match(r"\$(\d+)\[\*\]\.(\w+)", attr_value)
355
+ if match:
356
+ return int(match.group(1)), match.group(2)
357
+ # If the body is a list, iterate through each field
358
+ for field in body:
359
+ if isinstance(field["value"], str) and "[*]" in field["value"]: # string indices must be integers, not 'str'
360
+ match = re.match(r"\$(\d+)\[\*\]\.(\w+)", field["value"])
361
+ if match:
362
+ return int(match.group(1)), match.group(2)
363
+ return None
364
+
365
+
366
+ def call_llm(step, context, tool_context):
367
+ prompt = f"""
368
+ User Goal: {step.get('llm_goal')}
369
+ Step Result: {json.dumps(context[-1], indent=2)}
370
+
371
+ Based on this, generate the next tool_context step(s) as a JSON list.
372
+ """
373
+ try:
374
+ import openai
375
+ response = openai.chat.completions.create(
376
+ model="gpt-4",
377
+ messages=[{"role": "user", "content": prompt}],
378
+ temperature=0.0
379
+ )
380
+ return json.loads(response.choices[0].message.content)
381
+ except OpenAIError as e:
382
+ log.info(f"OpenAI error: {e}")
383
+ return []
384
+ except Exception as e:
385
+ log.info(f"Failed LLM call: {e}")
386
+ return []
387
+
388
+ def execute_api_step(step, step_num):
389
+ url = step["base_url"].rstrip("/") + "/" + step["path"].lstrip("/")
390
+ method = step["method"].upper()
391
+ # params = {p["name"]: p["val"] for p in step.get("query_params", [])}
392
+ params = get_query_param_filter(step.get("query_params", []))
393
+ # body = {p["name"]: p["value"] for p in step.get("body", [])} if step.get("body", []) else None # fixme: name?
394
+ body = {}
395
+ if step.get("body", []):
396
+ # eg: POST http://localhost:5656/api/SysEmail {'data': {'type': 'SysEmail', 'attributes': {'customer_id': 5, 'message': {'to': '{{ order.customer_id }}', 'subject': 'Discount for your order', 'body': 'Dear customer, you have a discount for your recent order. Thank you for shopping with us.'}}}}
397
+ body = {'data': {"type": step["path"].split("/")[-1], 'attributes': {}}} # eg: SysEmail
398
+ for each_field in step["body"]:
399
+ body['data']['attributes'].update(each_field) # each_field is a dict, eg: {'subject': 'Discount Offer', 'message': 'You have a new discount offer', 'customer_id': '$0[*].customer_id'}
400
+
401
+
402
+ log.info(f"\n\n➡️ MCP execute_api_step[{step_num}]:")
403
+ log.info(f" Method: {method} {url}")
404
+ log.info(f" Query: {params}")
405
+ log.info(f" Body: {body}\n")
406
+ try:
407
+ resp = requests.request(method, url, json=body if method in ["POST", "PATCH"] else None, params=params)
408
+ resp.raise_for_status()
409
+ return resp.json()
410
+ except requests.RequestException as e:
411
+ log.info(f"❌ Request failed: {e}")
412
+ return {}
413
+
414
+ step_num = 0
415
+ steps = tool_context["resources"]
416
+ for each_step in steps:
417
+
418
+ if each_step.get("llm_call"):
419
+ log.info(f"\n🔁 LLM Call triggered at step {i}")
420
+ new_steps = call_llm(each_step, context_results, tool_context)
421
+ tool_context[i+1:i+1] = new_steps
422
+ i += 1
423
+ continue
424
+
425
+ fan_out = find_fan_out_key(each_step)
426
+ if fan_out:
427
+ ref_idx, attr = fan_out
428
+ fan_out_list = context_results[ref_idx]
429
+ if isinstance(fan_out_list, dict) and "data" in fan_out_list:
430
+ fan_out_list = fan_out_list["data"]
431
+ for row in fan_out_list:
432
+ resolved = resolve_step(each_step, context_results, row, ref_idx)
433
+ result = execute_api_step(resolved, step_num)
434
+ context_results.append(result)
435
+ else:
436
+ resolved = each_step if len(context_results) == 0 else resolve_step(each_step, context_results)
437
+ result = execute_api_step(resolved, step_num)
438
+ context_results.append(result)
439
+ step_num += 1
288
440
 
289
- query = "list customers with balance over 100"
290
- prompt = get_user_nl_query_and_training(query) # set breakpoint here, view log, then step
441
+ log.info("\n✅ MCP Client Executor All Steps Executed\n")
291
442
 
292
- tool_context = query_llm_with_nl(schema_text, prompt) # see: 2-tool-context-from-LLM
443
+ return context_results
293
444
 
294
- mcp_response = process_tool_context(tool_context) # see: 3-MCP-server response
295
445
 
296
- print("\nTest complete.\n")
446
+
447
+ def mcp_client_executor(query: str):
448
+ """
449
+
450
+ #als: create an MCP request. See https://apilogicserver.github.io/Docs/Integration-MCP/
451
+
452
+ Test:
453
+ * curl -X 'POST' 'http://localhost:5656/api/SysMcp/' -H 'accept: application/vnd.api+json' -H 'Content-Type: application/json' -d '{ "data": { "attributes": {"request": "List the orders date_shipped is null and CreatedOn before 2023-07-14, and send a discount email (subject: '\''Discount Offer'\'') to the customer for each one."}, "type": "SysMcp"}}'
454
+ * Or, use the Admin App and insert a row into SysMCP (see default `query`, below)
455
+
456
+ Args:
457
+ query (str): The natural language query to process.
458
+ """
459
+
460
+ learnings_and_schema = discover_mcp_servers() # see: 1-discovery-from-als
461
+
462
+ tool_context = query_llm_with_nl(learnings_and_schema, query) # see: 2-tool-context-from-LLM
463
+
464
+ mcp_response = process_tool_context(tool_context) # see: 3-MCP-server response
465
+
466
+ log.info("\nTest complete.\n")
467
+
468
+ return tool_context, mcp_response
469
+
470
+
471
+ if __name__ == "__main__": # F5 to start API Logic Server
472
+
473
+ logging_config = f'{project_path}/config/logging.yml'
474
+ if os.getenv('APILOGICPROJECT_LOGGING_CONFIG'):
475
+ logging_config = project_path.joinpath(os.getenv("APILOGICPROJECT_LOGGING_CONFIG"))
476
+ with open(logging_config,'rt') as f: # see also logic/declare_logic.py
477
+ config=yaml.safe_load(f.read())
478
+ f.close()
479
+ logging.config.dictConfig(config) # log levels: notset 0, debug 10, info 20, warn 30, error 40, critical 50
480
+
481
+ query = default_query # default query if no argument is provided
482
+
483
+ if len(sys.argv) > 1: # if 1 non-blank argument, use it as the query
484
+ query = sys.argv[1]
485
+ if query == 'mcp':
486
+ query = default_query_email
487
+
488
+ mcp_client_executor(query)