Skip to content

Commit a36965a

Browse files
authored
Merge pull request #15 from signnow/feat/document-group-template-improvements
Feat/document group template improvements
2 parents 8c81a4f + 608e592 commit a36965a

File tree

8 files changed

+229
-106
lines changed

8 files changed

+229
-106
lines changed

examples/langchain/langchain_example.py

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,16 @@ async def main() -> None:
1414
os.environ.update(dotenv_values(env_path))
1515

1616
# MCP server as subprocess (example: your sn-mcp serve)
17-
client = MultiServerMCPClient(
18-
{
19-
"sn": {
20-
"transport": "stdio",
21-
"command": "sn-mcp",
22-
"args": ["serve"],
23-
# "cwd": "/path/to/dir",
24-
# "env": {"VAR": "value"},
25-
# "allowed_tools": ["list_templates", "get_template"],
26-
}
17+
client = MultiServerMCPClient({
18+
"sn": {
19+
"transport": "stdio",
20+
"command": "sn-mcp",
21+
"args": ["serve"],
22+
# "cwd": "/path/to/dir",
23+
# "env": {"VAR": "value"},
24+
# "allowed_tools": ["list_templates", "get_template"],
2725
}
28-
)
26+
})
2927

3028
tools = await client.get_tools() # MCP → LangChain tools
3129

@@ -36,13 +34,11 @@ async def main() -> None:
3634
temperature=0,
3735
)
3836

39-
prompt = ChatPromptTemplate.from_messages(
40-
[
41-
("system", "Be helpful."),
42-
("human", "{input}"),
43-
MessagesPlaceholder("agent_scratchpad"),
44-
]
45-
)
37+
prompt = ChatPromptTemplate.from_messages([
38+
("system", "Be helpful."),
39+
("human", "{input}"),
40+
MessagesPlaceholder("agent_scratchpad"),
41+
])
4642
agent = create_openai_tools_agent(llm, tools, prompt)
4743
execu = AgentExecutor(agent=agent, tools=tools, verbose=True)
4844

src/signnow_client/models/document_groups.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from typing import Any
88

9+
from typing_extensions import Self
10+
911
from pydantic import BaseModel, EmailStr, Field, HttpUrl
1012

1113

@@ -32,7 +34,7 @@ class DocumentGroupDocument(BaseModel):
3234
"""Document information within a document group."""
3335

3436
id: str = Field(..., description="Document ID")
35-
document_name: str = Field(..., description="Document name")
37+
document_name: str | None = Field(None, description="Document name")
3638
thumbnail: dict[str, str] = Field(..., description="Document thumbnails")
3739
roles: list[str] = Field(..., description="Roles defined for this document")
3840

@@ -150,7 +152,7 @@ class DocumentGroupTemplateRecipientAttributes(BaseModel):
150152
decline_redirect_uri: str | None = Field(None, description="URL after recipient declines document")
151153
close_redirect_uri: str | None = Field(None, description="URL after save progress or close")
152154

153-
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
155+
def model_dump(self: Self, **kwargs: Any) -> dict[str, Any]: # noqa: ANN401
154156
"""Override model_dump to exclude redirect_target if redirect_uri is not provided."""
155157
data = super().model_dump(**kwargs)
156158
if (not self.redirect_uri or not self.redirect_uri.strip()) and "redirect_target" in data:
@@ -213,7 +215,7 @@ class CreateDocumentGroupEmbeddedEditorRequest(BaseModel):
213215
link_expiration: int | None = Field(15, description="Link expiration in minutes (default: 15, max: 43200 for Admin users)")
214216
redirect_target: str | None = Field("self", description="Redirect target: 'blank' (new tab) or 'self' (same tab)")
215217

216-
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
218+
def model_dump(self: Self, **kwargs: Any) -> dict[str, Any]: # noqa: ANN401
217219
"""Override model_dump to exclude redirect_target if redirect_uri is not provided."""
218220
data = super().model_dump(**kwargs)
219221
if (not self.redirect_uri or not self.redirect_uri.strip()) and "redirect_target" in data:
@@ -229,7 +231,7 @@ class CreateDocumentGroupEmbeddedSendingRequest(BaseModel):
229231
link_expiration: int | None = Field(15, description="Link expiration in minutes (15-45, max: 43200 for Admin users)")
230232
type: str | None = Field("manage", description="Sending step: 'manage' (Add documents), 'edit' (editor), 'send-invite' (Send Invite page)")
231233

232-
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
234+
def model_dump(self: Self, **kwargs: Any) -> dict[str, Any]: # noqa: ANN401
233235
"""Override model_dump to exclude redirect_target if redirect_uri is not provided."""
234236
data = super().model_dump(**kwargs)
235237
if (not self.redirect_uri or not self.redirect_uri.strip()) and "redirect_target" in data:

src/sn_mcp_server/_version.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
commit_id: COMMIT_ID
2929
__commit_id__: COMMIT_ID
3030

31-
__version__ = version = '0.1.5.dev21+gb89924ba2.d20251104'
32-
__version_tuple__ = version_tuple = (0, 1, 5, 'dev21', 'gb89924ba2.d20251104')
31+
__version__ = version = "0.1.5.dev24+g1d96ac78f.d20251104"
32+
__version_tuple__ = version_tuple = (0, 1, 5, "dev24", "g1d96ac78f.d20251104")
3333

3434
__commit_id__ = commit_id = None

src/sn_mcp_server/auth.py

Lines changed: 49 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -70,20 +70,18 @@ def _verify_jwt(token: str) -> dict[str, Any] | None:
7070

7171
# ============= OAuth endpoints =============
7272
async def openid_config(_: Request) -> JSONResponse:
73-
return JSONResponse(
74-
{
75-
"issuer": str(settings.oauth_issuer),
76-
"authorization_endpoint": f"{str(settings.oauth_issuer)}authorize",
77-
"token_endpoint": f"{str(settings.oauth_issuer)}oauth2/token",
78-
"jwks_uri": f"{str(settings.oauth_issuer)}.well-known/jwks.json",
79-
"registration_endpoint": f"{str(settings.oauth_issuer)}oauth2/register",
80-
"scopes_supported": ["openid", "profile", "offline_access", "*"],
81-
"response_types_supported": ["code"],
82-
"grant_types_supported": ["authorization_code", "refresh_token"],
83-
"code_challenge_methods_supported": ["S256"],
84-
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
85-
}
86-
)
73+
return JSONResponse({
74+
"issuer": str(settings.oauth_issuer),
75+
"authorization_endpoint": f"{str(settings.oauth_issuer)}authorize",
76+
"token_endpoint": f"{str(settings.oauth_issuer)}oauth2/token",
77+
"jwks_uri": f"{str(settings.oauth_issuer)}.well-known/jwks.json",
78+
"registration_endpoint": f"{str(settings.oauth_issuer)}oauth2/register",
79+
"scopes_supported": ["openid", "profile", "offline_access", "*"],
80+
"response_types_supported": ["code"],
81+
"grant_types_supported": ["authorization_code", "refresh_token"],
82+
"code_challenge_methods_supported": ["S256"],
83+
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
84+
})
8785

8886

8987
async def oauth_as_meta(_: Request) -> JSONResponse:
@@ -134,15 +132,13 @@ async def token(req: Request) -> JSONResponse:
134132
return JSONResponse({"error": "external_token_error"}, status_code=500)
135133

136134
# Return tokens from SignNow API
137-
return JSONResponse(
138-
{
139-
"token_type": signnow_response.get("token_type", "Bearer"),
140-
"access_token": signnow_response.get("access_token"),
141-
"expires_in": signnow_response.get("expires_in", settings.access_ttl),
142-
"refresh_token": signnow_response.get("refresh_token"),
143-
"scope": "*",
144-
}
145-
)
135+
return JSONResponse({
136+
"token_type": signnow_response.get("token_type", "Bearer"),
137+
"access_token": signnow_response.get("access_token"),
138+
"expires_in": signnow_response.get("expires_in", settings.access_ttl),
139+
"refresh_token": signnow_response.get("refresh_token"),
140+
"scope": "*",
141+
})
146142

147143
elif grant_type == "refresh_token":
148144
refresh = form.get("refresh_token")
@@ -159,15 +155,13 @@ async def token(req: Request) -> JSONResponse:
159155
return JSONResponse({"error": "invalid_request", "error_description": "refresh_token must be a string"}, status_code=400)
160156

161157
if signnow_response:
162-
return JSONResponse(
163-
{
164-
"token_type": signnow_response.get("token_type", "Bearer"),
165-
"access_token": signnow_response.get("access_token"),
166-
"expires_in": signnow_response.get("expires_in", settings.access_ttl),
167-
"refresh_token": signnow_response.get("refresh_token"),
168-
"scope": signnow_response.get("scope", "*"),
169-
}
170-
)
158+
return JSONResponse({
159+
"token_type": signnow_response.get("token_type", "Bearer"),
160+
"access_token": signnow_response.get("access_token"),
161+
"expires_in": signnow_response.get("expires_in", settings.access_ttl),
162+
"refresh_token": signnow_response.get("refresh_token"),
163+
"scope": signnow_response.get("scope", "*"),
164+
})
171165
else:
172166
return JSONResponse({"error": "invalid_grant"}, status_code=400)
173167

@@ -185,17 +179,15 @@ async def introspect(req: Request) -> JSONResponse:
185179
active = claims is not None
186180
resp: dict[str, Any] = {"active": bool(active)}
187181
if active and claims:
188-
resp.update(
189-
{
190-
"iss": claims["iss"],
191-
"sub": claims["sub"],
192-
"aud": claims["aud"],
193-
"client_id": claims.get("client_id"),
194-
"scope": claims.get("scope", ""),
195-
"exp": claims["exp"],
196-
"iat": claims["iat"],
197-
}
198-
)
182+
resp.update({
183+
"iss": claims["iss"],
184+
"sub": claims["sub"],
185+
"aud": claims["aud"],
186+
"client_id": claims.get("client_id"),
187+
"scope": claims.get("scope", ""),
188+
"exp": claims["exp"],
189+
"iat": claims["iat"],
190+
})
199191
return JSONResponse(resp)
200192

201193

@@ -220,14 +212,12 @@ async def revoke(req: Request) -> PlainTextResponse | JSONResponse:
220212

221213
# ============= PRM (Protected Resource Metadata) =============
222214
def prm_for_resource(resource_url: str) -> JSONResponse:
223-
return JSONResponse(
224-
{
225-
"resource": resource_url,
226-
"authorization_servers": [str(settings.oauth_issuer)],
227-
"bearer_methods_supported": ["header"],
228-
"scopes_supported": ["openid", "profile", "offline_access", "*"],
229-
}
230-
)
215+
return JSONResponse({
216+
"resource": resource_url,
217+
"authorization_servers": [str(settings.oauth_issuer)],
218+
"bearer_methods_supported": ["header"],
219+
"scopes_supported": ["openid", "profile", "offline_access", "*"],
220+
})
231221

232222

233223
async def prm_root(_: Request) -> JSONResponse:
@@ -317,16 +307,14 @@ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None
317307
if not self.token_provider.has_config_credentials():
318308
token = self.token_provider.get_access_token(dict(request.headers))
319309
if not token:
320-
await send(
321-
{
322-
"type": "http.response.start",
323-
"status": 401,
324-
"headers": [
325-
(b"www-authenticate", f'Bearer resource_metadata="{str(settings.oauth_issuer)}/.well-known/oauth-protected-resource"'.encode()),
326-
(b"content-type", b"text/plain; charset=utf-8"),
327-
],
328-
}
329-
)
310+
await send({
311+
"type": "http.response.start",
312+
"status": 401,
313+
"headers": [
314+
(b"www-authenticate", f'Bearer resource_metadata="{str(settings.oauth_issuer)}/.well-known/oauth-protected-resource"'.encode()),
315+
(b"content-type", b"text/plain; charset=utf-8"),
316+
],
317+
})
330318
await send({"type": "http.response.body", "body": b"Unauthorized"})
331319
return
332320

src/sn_mcp_server/cli.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import typer
55
import uvicorn
66

7+
from ._version import __version__
78
from .server import create_server
89

910
app = typer.Typer(help="SignNow MCP server")
@@ -15,13 +16,15 @@
1516
def serve() -> None:
1617
"""Run MCP server in standalone mode"""
1718
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
19+
print(f"SignNow MCP Server v{__version__}", file=sys.stderr)
1820
mcp = create_server()
1921
mcp.run()
2022

2123

2224
@app.command()
23-
def http(host: str = "0.0.0.0", port: int = 8000, reload: bool = False) -> None:
25+
def http(host: str = "0.0.0.0", port: int = 8000, reload: bool = False) -> None: # noqa: S104
2426
"""Run HTTP server with MCP endpoints"""
27+
print(f"SignNow MCP Server v{__version__}", file=sys.stderr)
2528
uvicorn.run("sn_mcp_server.app:create_http_app", factory=True, host=host, port=port, reload=reload)
2629

2730

src/sn_mcp_server/tools/list_documents.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55
from the SignNow API and converting them to simplified formats for MCP tools.
66
"""
77

8-
from typing import Any
9-
108
from signnow_client import SignNowAPIClient
9+
from signnow_client.config import SignNowConfig
1110

1211
from .models import (
1312
SimplifiedDocumentGroup,
@@ -16,7 +15,7 @@
1615
)
1716

1817

19-
def _list_document_groups(token: str, signnow_config: Any, limit: int = 50, offset: int = 0) -> SimplifiedDocumentGroupsResponse:
18+
def _list_document_groups(token: str, signnow_config: SignNowConfig, limit: int = 50, offset: int = 0) -> SimplifiedDocumentGroupsResponse:
2019
"""Provide simplified list of document groups with basic fields.
2120
2221
Args:
@@ -37,7 +36,9 @@ def _list_document_groups(token: str, signnow_config: Any, limit: int = 50, offs
3736
for group in full_response.document_groups:
3837
simplified_docs = []
3938
for doc in group.documents:
40-
simplified_doc = SimplifiedDocumentGroupDocument(id=doc.id, name=doc.document_name, roles=doc.roles)
39+
# Use document_name if available, otherwise fallback to document ID
40+
document_name = doc.document_name if doc.document_name is not None else doc.id
41+
simplified_doc = SimplifiedDocumentGroupDocument(id=doc.id, name=document_name, roles=doc.roles)
4142
simplified_docs.append(simplified_doc)
4243

4344
simplified_group = SimplifiedDocumentGroup(

src/sn_mcp_server/tools/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ class InviteRecipient(BaseModel):
9393
role: str = Field(..., description="Recipient's role name in the document")
9494
message: str | None = Field(None, description="Custom email message for the recipient")
9595
subject: str | None = Field(None, description="Custom email subject for the recipient")
96-
action: str = Field(default='sign', description="Allowed action with a document. Possible values: 'view', 'sign', 'approve'")
96+
action: str = Field(default="sign", description="Allowed action with a document. Possible values: 'view', 'sign', 'approve'")
9797
redirect_uri: str | None = Field(None, description="Link that opens after completion")
9898
redirect_target: str | None = Field("blank", description="Redirect target: 'blank' for new tab, 'self' for same tab")
9999
decline_redirect_uri: str | None = Field(None, description="URL that opens after decline")
@@ -127,7 +127,7 @@ class EmbeddedInviteRecipient(BaseModel):
127127

128128
email: str = Field(..., description="Recipient's email address")
129129
role: str = Field(..., description="Recipient's role name in the document")
130-
action: str = Field(default='sign', description="Allowed action with a document. Possible values: 'view', 'sign', 'approve'")
130+
action: str = Field(default="sign", description="Allowed action with a document. Possible values: 'view', 'sign', 'approve'")
131131
auth_method: str = Field("none", description="Authentication method in integrated app: 'password', 'email', 'mfa', 'biometric', 'social', 'other', 'none'")
132132
first_name: str | None = Field(None, description="Recipient's first name")
133133
last_name: str | None = Field(None, description="Recipient's last name")

0 commit comments

Comments
 (0)